MVC: Unit testing controller actions that use TempData
Part of the "big deal" about ASP.NET MVC, and of course MVC in general, is that the code you write is more easily unit tested as compared to traditional ASP.NET WebForms. However, if you've used ASP.NET MVC and tried to write a unit test for a controller action that uses TempData, you probably got some exceptions about null references and other crazy stuff. I've seen a number of solutions out there that involve private reflection (which is kind of evil) so I figured I'd show how it can be done without that trickery. (Kudos, though, to those of you who figured out how to do such trickery!)
The underlying problem is that the TempDataDictionary object tries to read from session state when it's first used. At unit test time ASP.NET is not running so there isn't any session state. The solution is to provide a mock session state object to the TempDataDictionary. We're basically telling TempDataDictionary, "Hey you want some session state??? Here's your session state!!!"
The controller we're going to test has two actions. The first action adds a value to TempData and then does a redirect. The second action checks for the existence of the value in TempData and renders a view.
public class HomeController : Controller { public void Index() { // Save UserID into TempData and redirect to greeting page TempData["UserID"] = "user123"; RedirectToAction("GreetUser"); } public void GreetUser() { // Check that the UserID is present. If it's not // there, redirect to error page. If it is, show // the greet user view. if (!TempData.ContainsKey("UserID")) { RedirectToAction("ErrorPage"); return; } ViewData["NewUserID"] = TempData["UserID"]; RenderView("GreetUser"); } }
In order to write the unit tests I created a mock HttpContext with a mock session state object:
// HttpContext for TempData that uses a custom // session object. public class TestTempDataHttpContext : HttpContextBase { private TestTempDataHttpSessionState _sessionState = new TestTempDataHttpSessionState(); public override HttpSessionStateBase Session { get { return _sessionState; } } } // HttpSessionState for TempData that uses a custom // session object. public class TestTempDataHttpSessionState : HttpSessionStateBase { // This string is "borrowed" from the ASP.NET MVC source code private string TempDataSessionStateKey = "__ControllerTempData"; private object _tempDataObject; public override object this[string name] { get { Assert.AreEqual<string>( TempDataSessionStateKey, name, "Wrong session key used"); return _tempDataObject; } set { Assert.AreEqual<string>( TempDataSessionStateKey, name, "Wrong session key used"); _tempDataObject = value; } } }Each unit test that involves an action that uses TempData needs to create a custom TestTempDataHttpContext object and use that in a new instance of TempDataDictionary:
TestTempDataHttpContext tempDataHttpContext = new TestTempDataHttpContext(); homeController.TempData = new TempDataDictionary(tempDataHttpContext);
I wrote three unit tests for HomeController:
- Test that the Index action sets the right TempData and does a redirect
- Test that the GreetUser action redirects when the TempData is missing the value
- Test that the GreetUser action renders the GreetUser view with the right ViewData when the TempData has the right value
And here are the unit tests:
[TestClass] public class HomeControllerTest { [TestMethod] public void IndexSavesUserIDToTempDataAndRedirects() { // Setup TestHomeController homeController = new TestHomeController(); TestTempDataHttpContext tempDataHttpContext = new TestTempDataHttpContext(); homeController.TempData = new TempDataDictionary(tempDataHttpContext); // Execute homeController.Index(); // Verify Assert.IsTrue(homeController.RedirectValues.ContainsKey("action")); Assert.AreEqual("GreetUser", homeController.RedirectValues["action"]); Assert.IsTrue(homeController.TempData.ContainsKey("UserID")); Assert.AreEqual("user123", homeController.TempData["UserID"]); } [TestMethod] public void GreetUserWithNoUserIDRedirects() { // Setup TestHomeController homeController = new TestHomeController(); TestTempDataHttpContext tempDataHttpContext = new TestTempDataHttpContext(); homeController.TempData = new TempDataDictionary(tempDataHttpContext); // Execute homeController.GreetUser(); // Verify Assert.IsTrue(homeController.RedirectValues.ContainsKey("action")); Assert.AreEqual("ErrorPage", homeController.RedirectValues["action"]); Assert.AreEqual(0, homeController.TempData.Count); } [TestMethod] public void GreetUserWithUserIDCopiesToViewDataAndRenders() { // Setup TestHomeController homeController = new TestHomeController(); TestTempDataHttpContext tempDataHttpContext = new TestTempDataHttpContext(); homeController.TempData = new TempDataDictionary(tempDataHttpContext); homeController.TempData["UserID"] = "TestUserID"; // Execute homeController.GreetUser(); // Verify Assert.AreEqual<string>("GreetUser", homeController.RenderViewName); Assert.AreEqual<string>(String.Empty, homeController.RenderMasterName); IDictionary<string, object> viewData = homeController.RenderViewData as IDictionary<string, object>; Assert.IsNotNull(viewData); Assert.IsTrue(viewData.ContainsKey("NewUserID")); Assert.AreEqual("TestUserID", viewData["NewUserID"]); } // Test-specific subclass for HomeController. This won't be // needed in the next release of ASP.NET MVC. private sealed class TestHomeController : HomeController { public RouteValueDictionary RedirectValues; public string RenderViewName; public string RenderMasterName; public object RenderViewData; protected override void RedirectToAction(RouteValueDictionary values) { RedirectValues = values; } protected override void RenderView(string viewName, string masterName, object viewData) { RenderViewName = viewName; RenderMasterName = masterName; RenderViewData = viewData; } } }
Some notes about the code:
- For the unit tests I also needed to create a test-specific subclass for HomeController called TestHomeController in order to capture the values of a call to RedirectToAction and RenderView. In the upcoming preview of ASP.NET MVC this class won't be needed due to some changes we've made.
- Making TempData easier to unit test is on our list of improvements for a later preview of ASP.NET MVC. We realize it's a bit tricky right now, and we plan to make it much, much easier.
- Instead of creating concrete classes for the custom HttpContext and session state objects you could use a mock object framework to dynamically generate those instances. I chose not to show that since it might detract from the purpose of the post. If you're already using a mock object framework such as RhinoMocks or Moq it should be easy to modify the code to use those frameworks.
I hope you find this sample useful when you're writing your unit tests. If you have any other pain points in ASP.NET MVC please post a comment so we'll know about it.