Tuesday, July 21, 2009

Unit Testing Asp.net MVC and the Cache

If you haven’t worked with Stephen Walthers MVC Tip #12, you’re missing out on a great way to test your MVC controllers. He provides fakes for the ControllerContext, HttpContext, HttpRequest, SessionState and also mechanisms for testing authorization. You can download his code at the end of the post.

I created a new project called MvcFakesForTesting and copied all of the classes. I did this so I can extend and grow the project. Stephen wrote his library with pre-release code, so I needed to change a couple of IController references to ControllerBase. I also created added Headers so that I can fake the request headers. Just implement this the same as QueryString and FormParams. Now I have the DLL that I will use on all of our projects for testing. I made sure to include a reference to Stephen so that credit is given where credit is due.

One thing that is not included is a fake for the Cache. Of course, I ran into this on my first test! First of all, the Cache object is sealed / notinheritable and doesn’t have an interface so mocking or faking it is not going to work. We’re going to need a wrapper.

Step one is to create an interface so I can create use dependency injection for testing. ICacheWrapper is simply all of the public methods from Cache.

public interface ICacheWrapper
{
int Count { get; }

object this[string key] { get; set; }

object Add(string key,
object value,
CacheDependency dependencies,
DateTime absoluteExpiration,
TimeSpan slidingExpiration,
CacheItemPriority priority,
CacheItemRemovedCallback onRemoveCallback);

object Get(string key);

void Insert(string key, object value);

void Insert(string key, object value, CacheDependency dependencies);

void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration);

void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, CacheItemUpdateCallback onUpdateCallback);

void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, CacheItemPriority priority, CacheItemRemovedCallback onRemoveCallback);

object Remove(string key);
}

Now I can create  CacheWrapper that implements ICacheWrapper and defers the call to the actual cache.

public class CacheWrapper : ICacheWrapper
{
private Cache cache;

public CacheWrapper()
{
this.cache = HttpContext.Current.Cache;
}

public CacheWrapper(Cache cache)
{
this.cache = cache;
}

#region ICacheWrapper Members

public int Count
{
get { return this.cache.Count; }
}

public object this[string key]
{
get
{
return this.cache[key];
}
set
{
this.cache.Insert(key, value);
}
}

public object Add(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, CacheItemPriority priority, CacheItemRemovedCallback onRemoveCallback)
{
return this.cache.Add(key, value, dependencies, absoluteExpiration, slidingExpiration, priority, onRemoveCallback);
}

public object Get(string key)
{
return this.cache[key];
}

public void Insert(string key, object value)
{
this.cache.Insert(key, value);
}

public void Insert(string key, object value, CacheDependency dependencies)
{
this.cache.Insert(key, value, dependencies);
}

public void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration)
{
this.cache.Insert(key, value, dependencies, absoluteExpiration, slidingExpiration);
}

public void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, CacheItemUpdateCallback onUpdateCallback)
{
this.cache.Insert(key, value, dependencies, absoluteExpiration, slidingExpiration, onUpdateCallback);
}

public void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, CacheItemPriority priority, CacheItemRemovedCallback onRemoveCallback)
{
this.cache.Insert(key, value, dependencies, absoluteExpiration, slidingExpiration, priority, onRemoveCallback);
}

public object Remove(string key)
{
return this.cache.Remove(key);
}

#endregion
}

Now, anywhere I want to access the cache, I create a link to ICacheWrapper and provide a mechanism for injecting the wrapper. This makes the code the same whether I’m testing for production.

public class CustomersProxy
{
private ICacheWrapper cache;
private ICustomersRepository repository;

public CustomersProxy(ICustomersRepository repository, ICacheWrapper cache)
{
this.repository = repository;
this.cache = cache;
}

public List<Customer> ListCustomers()
{
List<Customer> customers = (List<Customer>)this.cache[CustomersCacheKey];
if (customers == null)
{
customers = repository.FindAll();
this.cache.Add(
CustomersCacheKey,
customers ,
null,
Cache.NoAbsoluteExpiration,
new TimeSpan(1, 0, 0),
CacheItemPriority.AboveNormal,
null);
}
return customers;
}
}

FakeCacheWrapper uses a Dictionary to act as the cache. It implements ICacheWrapper so we can inject it for testing anywhere we need to call the cache.

public class FakeCacheWrapper : ICacheWrapper
{
private readonly Dictionary<string, object> cacheItems;

public static readonly DateTime NoAbsoluteExpiration;

public static readonly TimeSpan NoSlidingExpiration;

public FakeCacheWrapper()
{
this.cacheItems = new Dictionary<string, object>();
}

public int Count { get { return this.cacheItems.Count; } }

public object this[string key]
{
get
{
return Get(key);
}
set
{
cacheItems.Add(key, value);
}
}

public object Add(string key,
object value,
CacheDependency dependencies,
DateTime absoluteExpiration,
TimeSpan slidingExpiration,
CacheItemPriority priority,
CacheItemRemovedCallback onRemoveCallback)
{
this.cacheItems.Add(key, value);
return value;
}

public object Get(string key)
{
if (this.cacheItems.ContainsKey(key))
{ return this.cacheItems[key]; }
return null;
}

public IDictionaryEnumerator GetEnumerator()
{ return this.cacheItems.GetEnumerator(); }

public void Insert(string key, object value)
{
this.cacheItems.Add(key, value);
}

public void Insert(string key, object value, CacheDependency dependencies)
{
this.cacheItems.Add(key, value);
}

public void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration)
{
this.cacheItems.Add(key, value);
}

public void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, CacheItemUpdateCallback onUpdateCallback)
{
this.cacheItems.Add(key, value);
}

public void Insert(string key, object value, CacheDependency dependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, CacheItemPriority priority, CacheItemRemovedCallback onRemoveCallback)
{
this.cacheItems.Add(key, value);
}

public object Remove(string key)
{
object obj = null;
if (this.cacheItems.ContainsKey(key))
{
obj = this.cacheItems[key];
}
this.cacheItems.Remove(key);
return obj;
}
}
Now my controller has an two constructors, one for production and one for testing.
public class CustomersController : Controller
{
private CustomersProxy proxy;

public CustomersController()
{
CacheWrapper wrapper = new CacheWrapper(HttpContext.Current.Cache);
CustomersRepository repository = new CustomersRepository();
this.proxy = new CustomersProxy(repository, wrapper);
}

public CustomersController(CustomersProxy proxy)
{
this.proxy = proxy;
}

.....
}

With Stephen’s fakes and ICacheWrapper/FakeCacheWrapper, I can now test just about everything I need to.

5 comments:

Tony Chevis said...

Thanks for posting this and saving us some time!

PretzelSteelersFan said...

Glad you found it helpful!!

Anonymous said...

Thx alot for your code! It put me on the right track again.

Stephen Cawood said...

Hey, the download link on http://stephenwalther.com/archive/2008/07/01/asp-net-mvc-tip-12-faking-the-controller-context.aspx is broken. Can you share a download?

Anonymous said...

Thanks for putting this together. It is exactly what I need. Too bad the MVC framework doesn't already have something like this in place. -John