The using statement is widely used for cleaning up resources, such as a database connection or file handle. To use the using statement, the variable being scoped just needs to implement IDisposable. There are many times when I've needed to create a scope for behavior, and I've taken advantage of IDisposable to do this.
The problem
When I'm writing unit tests for a specific module of code, often times I would like to inject a different implementation of certain services at runtime without having to change the original code. For example, I have a class that calls into the ConfigurationManager to set some internal configuration values:
public class DatabaseReader
{
public DataSet GetOrdersForCustomer(Guid customerId)
{
string connString = ConfigurationManager.AppSettings["DBConnString"];
OracleConnection conn = new OracleConnection(connString);
…
//return dataset
}
}
Well, if I want to test this in a unit test, I won't be able to because I'm coupled to the ConfigurationManager class, which requires a .config file. This file doesn't exist in my test library, so this block will throw an exception.
Some refactoring
What if I try to move the configuration logic outside this class into its own implementation? Slap in an interface and a factory, and this is what we get:
public interface IConfigurationProvider
{
string GetValue(string key);
}
public class ConfigurationProviderFactory
{
public static IConfigurationProvider GetInstance()
{
return new SettingsConfigProvider();
}
private class SettingsConfigProvider : IConfigurationProvider
{
public string GetValue(string key)
{
return ConfigurationManager.AppSettings[key];
}
}
}
public class DatabaseReader
{
public DataSet GetOrdersForCustomer(Guid customerId)
{
IConfigurationProvider configProvider = ConfigurationProviderFactory.GetInstance();
string connString = configProvider.GetValue("DBConnString");
OracleConnection conn = new OracleConnection(connString);
…
//return dataset
}
}
The GetOrdersForCustomer method now uses an IConfigurationProvider instance to get its configuration. A specific instance is provided by the ConfigurationProviderFactory.
My broken unit test
But I'm not much better off. I created a whole layer of indirection just so I could factor out the configuration code. Here's the test code I'm trying to write:
[Test]
public void CustomerContainsCorrectOrders()
{
IConfigurationProvider fakeProvider = CreateFakeConfigProvider(); // returns a hard-coded implementation
DatabaseReader reader = new DatabaseReader();
Guid customerId;
// Now I want to use my fake configuration provider instead of the built-in one
DataSet ds = reader.GetOrdersForCustomer(customerId);
Assert.AreEqual(1, ds.Tables[0].Rows.Count);
}
My ideal unit test
I'm going to add some functionality to the ConfigurationProviderFactory to be able to return an IConfigurationProvider instance I give it when GetInstance is called. Also, I'd like to encapsulate this behavior in a using block like so:
[Test]
public void CustomerContainsCorrectOrders()
{
IConfigurationProvider fakeProvider = CreateFakeConfigProvider(); // returns a hard-coded implementation
DatabaseReader reader = new DatabaseReader();
Guid customerId;
using (ConfigurationProviderFactory.CreateLocalInstance(fakeProvider)) // use the fakeProvider instance inside this block
{
DataSet ds = reader.GetOrdersForCustomer(customerId);
}
Assert.AreEqual(1, ds.Tables[0].Rows.Count);
}
The solution
So this code implies that CreateLocalInstance needs to return an IDisposable instance. I don't have one yet, so let's create one. What I'd like to happen is for CreateLocalInstance to save the IConfigurationProvider passed in as a local variable, and have the GetInstance return that instead. Once I'm outside the using block, GetInstance should revert back to normal. I need something that will allow custom code to be run at the end of the using block in the Dispose method to accomplish this. Here's what I came up with, borrowed from ideas from Ayende:
public delegate void DisposableActionCallback();
public class DisposableAction : IDisposable
{
private readonly DisposableActionCallback _callback;
public DisposableAction(DisposableActionCallback callback)
{
_callback = callback;
}
public void Dispose()
{
_callback();
}
}
What DisposableAction allows me to do is to pass custom code via a callback method to be called whenever the DisposableAction goes out of scope. That will be what we return from our CreateLocalInstance method:
public class ConfigurationProviderFactory
{
private IConfigurationProvider _localProvider = null;
public static IConfigurationProvider GetInstance()
{
if (_localProvider != null)
return _localProvider;
return new SettingsConfigProvider();
}
public static IDisposable CreateLocalInstance(IConfigurationProvider localProvider)
{
_localProvider = localProvider;
return new DisposableAction(delegate
{
_localProvider = null;
});
}
...
}
I actually added two things in this code block. First, I created the CreateLocalInstance method that uses a DisposableAction. It sets the private _localProvider to what was passed in. Next, it returns a DisposableAction with an anonymous method callback to set the _localProvider back to null.
The other change I made was I modified the GetInstance method to be aware of the _localProvider variable and return it instead. The effect is a using block calling the CreateLocalInstance will make the GetInstance method return the new local IConfigurationProvider instead.
Closing comments
I was able to change the configuration code inside the GetCustomerOrders to use an abstraction of the IConfigurationProvider and created a factory which allowed me to inject custom IConfigurationProvider implementations without affecting any production code. And, I was able to take advantage of IDisposable and the using statement to do this. Obviously, I'll need additional tests to confirm existing functionality isn't broken.
But my test passes, and in the meantime I've opened the DatabaseReader class for extensibility for other IConfigurationProvider implementations, like one from pulling from the database or from SiteInfo.
1 comment:
Good stuff, just copied your approach and it works a treat.
Post a Comment