ASP.NET MVC + MVC Contrib + Unit Testing

One of the key benefits of the MVC (Model View Controller) pattern is a separation of concerns that leads to better testability.  Microsoft recognizes this and will automatically create a separate MS Test project when creating a new ASP.NET MVC solution.  While this gives you a nice head start, there's room for improvement.  While actions in the MVC pattern are simply methods on a class that can easily be called by MSTest (or any unit testing framework), most web applications have interactions with supporting objects such a Request (query string, form parameters, etc…), Response (cookies, content type, headers, etc…), Session, and more.  In a live environment, these objects come as a result of the HTTP request being processed by IIS.  In a test environment, you're isolating just your controllers and actions and you don't have IIS and an entire HTTP pipeline.

We can use mocking to provide "pretend" implementations of all of these objects, but there's a lot to mock.  This is where the MVC Contrib project on CodePlex can really come in handy!

UPDATE: The code for this entire project is available in ZIP format from my Google Code page or you can do an SVN checkout of http://patricksteele.googlecode.com/svn/trunk/UnitTestingAspNetMVC.

MVC Contrib Test Helper

The MVC Contrib project gets a lot of praise for the many benefits it brings when developing for ASP.NET MVC – numerous UI helpers, Model Binders, Controller factories, etc…  But it also contains a TestHelper library that makes unit testing your controllers much easier.  By utilizing Rhino.Mocks, the MVC Contrib TestHelper can create and initialize your controller with mocked instances of:

  • HttpRequest
  • HttpResponse
  • HttpSession
  • Form
  • HttpContext
  • and more!

In this article, I'll utilize the MVC Contrib TestHelper library to fully unit test a simple ASP.NET MVC controller.  As with many small demos, it's totally contrived, but helps illustrate the principals.

Scenario

We're building an ASP.NET MVC project that has to accept submissions from speakers.  We'll be doing this in a "wizard-like" fashion.  The prospective speaker will first enter their personal information (first name and last name).  The next step will have them enter their submission information.  There will be a final review point and finally, the actual submission.  Since some speakers want to submit multiple talks, we'll re-display the speakers personal information if they start the wizard up again after submitting a talk.

Design

In this article, we're only going to deal with the first step: collecting the speakers personal information (first name and last name).  We'll save this information in the Session object and pull it back out if the speaker returns to the beginning of the wizard after submitting a talk.  Both the speakers first name and last name are required – the user can not continue if they aren't both filled in.  The action on the controller is the "Speaker" action.  The next step in the wizard is the "SessionDetails" action.

Using the information above, we have 5 test scenarios to cover:

  1. If we run the "Speaker" action with nothing in the Session: The result should be a ViewModel which has no speaker first name/last name and we should return a "View" result to ASP.NET MVC.
  2. If we run the "Speaker" action with a first name/last name in the Session: The result should be a ViewModel which as the first name/last name from the Session and should return a "View" result to ASP.NET MVC.
  3. If we run the "Speaker" action and only send in a first name: We should save the first name to the Session, but we'll also add an error for the missing last name to the ModelState and return a "Redirect" result so ASP.NET MVC will go back to the "Speaker" action and give the user the chance to enter their missing last name.
  4. Do the same test as above, but for the last name.
  5. If we run the "Speaker" action and send both a first name and a last name: The first and last name should be saved to the Session object and a "Redirect" result should be returned that tells ASP.NET MVC to go to the "SessionDetails" action.

Set Up

Start up a new ASP.NET MVC project and make sure you select the option to create unit tests.  The default ASP.NET MVC project comes with a Home controller.  For simplicity's sake, we'll add our actions to that controller and add our tests to the HomeControllerTests class.

First, let's create a class to maintain our speaker information.  Sure, it's only two pieces of information, but this is a sample.  In the real world, we'd probably have more than this and therefore, we'll use a class:

public class SpeakerInfo
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

Test #1

We start by writing our failing test (red), we'll throw together enough production code to get the test to pass (green), then we'll implement our actual logic and make sure our tests still pass (refactor).

[TestMethod]
public void Speaker_WithoutSessionData_Returns_EmptyModel()
{
    var controller = new HomeController();
 
    var result = (ViewResult)controller.Speaker();
    var info = (SpeakerInfo)result.ViewData.Model;
 
    Assert.IsNull(info.FirstName);
    Assert.IsNull(info.LastName);
}

All we did here was create our controller and call the Speaker method.  This of course won't even compile so let's create a simple "Speaker" action on our home controller to get it to compile:

public ActionResult Speaker()
{
    return View();
}

Ok, now we compile and run the test.  We get a failure (red) because we don't have any view model.  Let's change our Speaker method so we have a passing unit test:

public ActionResult Speaker()
{
    return View(new SpeakerInfo());
}

Run the test again and we're green!  Now it's time to refactor into real code.

We're going to store the SpeakerInfo in the ASP.NET Session object so it's available later if the speaker decides to submit another talk (they won't have to re-enter their personal information).  Likewise, if we look in the Session and don't see a saved SpeakerInfo, we provide an empty one.  Let's put all of this into a property:

private SpeakerInfo SpeakerInfo
{
    get
    {
        var info = Session[SessionKeys.SpeakerInfoKey] as SpeakerInfo;
        if (info == null)
        {
            info = new SpeakerInfo();
        }
 
        return info;
    }
    set
    {
        Session[SessionKeys.SpeakerInfoKey] = value;
    }
}

NOTE: You'll notice that the code references an object calls "SessionKeys".  I don't like magic strings so I usually create a simple static class that has const fields for all of my session keys.  This has the added benefit of giving me some intellisense during development:

public static class SessionKeys
{
    public const string SpeakerInfoKey = "SI";
}

Now we refactor our original Speaker implementation to use the SpeakerInfo property instead of always creating a new SpeakerInfo object:

public ActionResult Speaker()
{
    return View(this.SpeakerInfo);
}

Now re-run the test and what do we get?  Red (an error)!  Why?  Because there is no Session object!  We're not running under ASP.NET so we'll have to mock out the Session object.  MVC Contrib TestHelper to the rescue!

Since we'll be using this controller throughout all of these tests, let's create a single method (DRY) to create the controller and mock out the supporting ASP.NET objects:

private static HomeController CreateController()
{
    TestControllerBuilder builder = new TestControllerBuilder();
    return builder.CreateController<HomeController>();
}

That's it!  The HomeController returned by this method has mocked implementations of all of the major ASP.NET objects (Session, Request, Response, etc…).  Let's change our unit test to use the CreateController method:

[TestMethod]
public void Speaker_WithoutSessionData_Returns_EmptyModel()
{
    var controller = CreateController();
 
    var result = (ViewResult)controller.Speaker();
    var info = (SpeakerInfo)result.ViewData.Model;
 
    Assert.IsNull(info.FirstName);
    Assert.IsNull(info.LastName);
}

Re-run the test and now we're green!  Let's move to the next test.

Test #2

This test makes sure that if the Session does contain a SpeakerInfo object, we return that SpeakerInfo object.  This handles the case where a speaker submits one talk and then starts over again to submit another talk – their personal information will be re-displayed.  Let's write our test.  Remember, since we've got a fully mocked Session object, we can access it like a regular object and place things in it directly:

[TestMethod]
public void Speaker_WithSessionData_Returns_PopulatedModel()
{
    var controller = CreateController();
    controller.Session[SessionKeys.SpeakerInfoKey] = new SpeakerInfo {FirstName = "Bob", LastName = "Smith"};
 
    var result = (ViewResult)controller.Speaker();
    var info = (SpeakerInfo)result.ViewData.Model;
 
    Assert.AreEqual("Bob", info.FirstName);
    Assert.AreEqual("Smith", info.LastName);
}

If we run this test, we're already green.  That's because our SpeakerInfo property we implemented earlier already had the logic for both saving and retrieving a SpeakerInfo instance from the Session.

Test #3

This tests make sure we return back to the Speaker action and display errors if the user only entered their first name.  Let's start with our test:

[TestMethod]
public void Data_Posted_Without_LastName_Returns_Error()
{
    var controller = CreateController();
    var result = (RedirectToRouteResult)controller.Speaker("jim", "");
    var info = (SpeakerInfo)controller.Session[SessionKeys.SpeakerInfoKey];
 
    Assert.AreEqual("jim", info.FirstName);
    Assert.AreEqual("", info.LastName);
    Assert.AreEqual(1, controller.ModelState.Count);
    Assert.IsTrue(controller.ModelState.ContainsKey("lastName"));
    result.AssertActionRedirect().ToAction<HomeController>(c => c.Speaker());
}

Take a look at that last line.  That's an extension method in the MVC Contrib TestHelper library.  By utilizing lambdas, it gives us a very nice way of asserting that the RedirectToRouteResult is going to a specific controller and action.  Without it you'd need to write code like:

Assert.AreEqual("Speaker", result.RouteValues["action"]);
Assert.AreEqual("Home", result.RouteValues["controller"]);

Magic strings?  Yuck!  No refactor support?  Double-yuck!  That AssertActionRedirect extension methods kicks butt!

We now have code that won't compile because we don't have a "SpeakerInfo" method that accepts a first name and last name.  Let's get our code compiling by implementing the bare-minimum production code:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Speaker(string firstName, string lastName)
{
    return null;
}

NOTE: We added the AcceptVerbs attribute on the method since we only want this method called via a <form> POST.  Also notice that we're taking advantage of the ASP.NET binder and we don't need to dig around the Request.QueryString or Request.Form variables – as long as the Request data coming in contains a parameter called "firstName" and one called "lastName", it will populate those parameters when calling our method.

Now we're compiling – but we're also seeing red in our unit test.  That's because our expectation is that we'll save the information we're given (first name) in the Session object.  But even if we implement that, our other expectation is that we're going to add a model error to the ModelState property for the missing last name.  Let's implement those behaviors now.

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Speaker(string firstName, string lastName)
{
    this.SpeakerInfo = new SpeakerInfo { FirstName = firstName, LastName = lastName };
    if (String.IsNullOrEmpty(lastName))
    {
        ModelState.AddModelError("lastName", "Last Name is Reqiured.");
    }
 
    return this.RedirectToAction(c => c.Speaker());
}

Run out tests and we're green!  We've now written 3 of our 5 unit tests and all of them pass.

Test #4

This one is the same as the last one, but handles the case where the first name is not entered by the user.  Remember, test first!

[TestMethod]
public void Data_Posted_Without_FirstName_Returns_Error()
{
    var controller = CreateController();
    var result = (RedirectToRouteResult)controller.Speaker("", "jones");
    var info = (SpeakerInfo)controller.Session[SessionKeys.SpeakerInfoKey];
 
    Assert.AreEqual("", info.FirstName);
    Assert.AreEqual("jones", info.LastName);
    Assert.AreEqual(1, controller.ModelState.Count);
    Assert.IsTrue(controller.ModelState.ContainsKey("firstName"));
    result.AssertActionRedirect().ToAction<HomeController>(c => c.Speaker());
}

The code compiles fine but when we run it, we'll see red.  We haven't implemented any logic to check for a blank first name.  Let's do that now to our existing Speaker(string,string) method:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Speaker(string firstName, string lastName)
{
    this.SpeakerInfo = new SpeakerInfo { FirstName = firstName, LastName = lastName };
    if (String.IsNullOrEmpty(lastName))
    {
        ModelState.AddModelError("lastName", "Last Name is Reqiured.");
    }
    if (String.IsNullOrEmpty(firstName))
    {
        ModelState.AddModelError("firstName", "First Name is Reqiured.");
    }
 
    return this.RedirectToAction(c => c.Speaker());
}

Our implementation is complete and now we've got a passing test.

But looking this over, it seems we're missing a test.  What if both first name AND last name are empty?  One of the nice things about test-driven-development is that things like this (a missing test) can come out during the process.  Let's make sure our code will handle this by writing a unit tests.  Granted, we can all probably tell from reading the code (all 6 lines of it – not including braces!) that the code will handle it, but we still need to write the test to make sure no one breaks this expectation in the future if they modify the code.

Our test case will look very similar to #3 and #4, except both first and last name should be blank and we'll be expecting two errors in ModelState:

[TestMethod]
public void Data_Posted_Blank_Returns_Error()
{
    var controller = CreateController();
    var result = (RedirectToRouteResult)controller.Speaker("", "");
    var info = (SpeakerInfo)controller.Session[SessionKeys.SpeakerInfoKey];
 
    Assert.AreEqual("", info.FirstName);
    Assert.AreEqual("", info.LastName);
    Assert.AreEqual(2, controller.ModelState.Count);
    Assert.IsTrue(controller.ModelState.ContainsKey("firstName"));
    Assert.IsTrue(controller.ModelState.ContainsKey("lastName"));
    result.AssertActionRedirect().ToAction<HomeController>(c => c.Speaker());
}

Run the test: Green!  Only one more test to go!

Test #5

This test ensures that if the speaker provides both their first name and last name, we'll save that info into the Session object and re-direct them to the next phase of the submission wizard – the "SessionDetails" method:

[TestMethod]
public void Data_Posted_To_Speaker_Saves_To_Session_and_Redirects()
{
    var controller = CreateController();
 
    var result = (RedirectToRouteResult)controller.Speaker("jon", "jones");
    result.AssertActionRedirect().ToAction<HomeController>(c => c.SessionDetails());
 
    var info = (SpeakerInfo)controller.Session[SessionKeys.SpeakerInfoKey];
    Assert.AreEqual("jon", info.FirstName);
    Assert.AreEqual("jones", info.LastName);
}

Compiling this fails as we don't have a SessionDetails() method yet.  Let's add one to our controller so we can run the test:

public ActionResult SessionDetails()
{
    return View();
}

Compile and run the test.  Red!  Remember the last line of Speaker(string,string) is always re-directing back to Speaker.  We haven't had a need for it to do anything else until now.  So now it's time to refactor the Speaker(string,string) method that that we'll only redirect back to Speaker if there's any errors.  Otherwise, we'll re-direct to the SessionDetails action:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Speaker(string firstName, string lastName)
{
    this.SpeakerInfo = new SpeakerInfo { FirstName = firstName, LastName = lastName };
    if (String.IsNullOrEmpty(firstName))
    {
        ModelState.AddModelError("firstName", "First Name is Reqiured.");
    }
    if (String.IsNullOrEmpty(lastName))
    {
        ModelState.AddModelError("lastName", "Last Name is Reqiured.");
    }
 
    if (ModelState.Count != 0)
    {
        return this.RedirectToAction(c => c.Speaker());
    }
 
    return this.RedirectToAction(c => c.SessionDetails());
}

Compile and run the test.  Green!  Now re-run all of the tests.  Green!

Conclusion

If you've stuck around this long – Thank You!  This has been a long post, but we started with no code and ended up developing an ASP.NET MVC Controller action and a full set of automated unit tests.  We've taken advantage of the MVC Contrib project to help make the tests easier to write.  We have complete code-coverage in our tests of all of the expectations on the "Speaker" method.  We can move forward developing the code and we have confidence that further refactorings won't break our code since we have our unit tests to verify everything!

1 Comment

Comments have been disabled for this content.