Thursday, January 24, 2008

Writing unit tests for an MVC Framework controller sucks

Over the Christmas holidays I had a lot of fun writing a web application using the Castle Project's Monorail. One of the joys of doing Monorail development is the outstanding support for unit testing that it provides. That's not really surprising when you read some of the Castle Project guys' blogs, they are all thoroughly Test Infected, and indeed one of the main driving forces behind adopting any framework that allows a separation of concerns is the ease which such a framework can support Test Driven Development.

During the last couple of weeks I've been embarking on a new commercial project. I've decided to use the new Microsoft MVC Framework simply because it's much easier to justify using a tool when Microsoft are behind it. And looking to the future it's highly likely that most professional MS shops will have MVC Framework skills to support such an application whereas Monorail skills are inevitably always going to be niche.

Monorail and the MVC Framework are quite similar, and having come from a Monorail project, I've found it pretty easy getting up to speed with the Microsoft offering. Some things are nicer: there's the typically good tool support, especially intellisense in the views and the strongly typed view data is an improvement over NVelocity (I haven't tried Brail). The routing engine is very nicely thought out and much better than the afterthought bolted onto Monorail.

The one thing that's really surprised me though is the lack of thought which has gone into support for unit testing controllers. Monorail supplies a BaseControllerTest class that you can inherit from for your controller tests that allows you to mock or access all the controller infrastructure. For example, say I want to test that my controller calls Response.Redirect passing a specific URL.

  • Make my controller test inherit from BaseControllerTest
  • Override the virtual method BuildResponse and provide a mock response
  • Pass my controller to the PrepareController method in BaseControllerTest. This sets up a mock infrastructure for the controller and at some point will call my overriden BuildResponse method.
  • In the test itself simply set an expectation for Response.Redirect to be called with the expected URL.
[TestFixture]
public class PosterLoginControllerTests : BaseControllerTest
{
    MockRepository mocks;
    PosterLoginController posterLoginController;

....

    protected override IMockResponse BuildResponse()
    {
        return mocks.CreateMock<IMockResponse>();
    }

    [SetUp]
    public void SetUp()
    {
  ....
  
        PrepareController(posterLoginController);
    }

    [Test]
    public void IndexShouldRedirectLoggedInPosterToAddJobTest()
    {
        string expectedRedirectUrl = "/Job/JobForm.rails";

        using (mocks.Record())
        {
   ....
   
            Response.Redirect(expectedRedirectUrl);
        }

        using (mocks.Playback())
        {
            posterLoginController.Index();
        }
    }

Now you can't do anything like this with the MVC framework. There's no BaseControllerTest and it's impossible to provide mocks for all the hooks in the Controller class. It's not completely decoupled from the infrastructure. The only way to successfully test a controller is to write a test subclass that inherits from the controller under test and overrides all the important methods that deal with the infrastructure. Because you have to write a new one for each controller it's a real overhead with lots of duplicated effort. Here's an example that I've had to write for my current project:

public class TestUserController : UserController
{
    string actualViewName;

    public string ActualViewName
    {
        get { return actualViewName; }
    }

    object actualViewData;

    public object ActualViewData
    {
        get { return actualViewData; }
    }

    Dictionary<string, object> redirectValues = new Dictionary<string, object>();

    public Dictionary<string, object> RedirectValues
    {
        get { return redirectValues; }
    }

    public TestUserController(
        IUserRepository userRepository,
        IRepository<Role> roleRepository,
        IRepository<InstructionType> instructionTypeRepository,
        IAuthorisationBuilder authorisationBuilder)
        : base(
        userRepository,
        roleRepository,
        instructionTypeRepository,
        authorisationBuilder)
    {

    }

    protected override void RenderView(string viewName, string masterName, object viewData)
    {
        this.actualViewName = viewName;
        this.actualViewData = viewData;
    }

    protected override void RedirectToAction(object values)
    {
        PropertyInfo[] properties = values.GetType().GetProperties();
        foreach (PropertyInfo property in properties)
        {
            redirectValues.Add(property.Name, property.GetValue(values, null));
        }
    }

    NameValueCollection form = new NameValueCollection();

    public override NameValueCollection Form
    {
        get
        {
            return form;
        }
    }
}

As you can see it's quite extensive and not something that I relish repeating for each controller. Phil Haack describes injecting mocks into the controller, but then in an update to the post says that it's something that now broken for the CTP and basically admits that the only way of doing it is to write test specific subclasses.

The more I do IoC and dependency injection (which works well with the MVC framework BTW) the more I wish framework builders did the same. It would make all these issues go away. I guess it's a request too far as long as IoC Containers remain a niche thing; supporting them is one thing, requiring them quite another.

Update

John Rayner from Conchango recently pointed out a post on Ayende's blog, showing how you can mock controllers and use extension methods to provide mocks of protected methods on the controller. It's a neat trick and gets over the need to build test sub-classes of your controller. But, it still doesn't remove the main point which is: we shouldn't have to do these crazy tricks; the controller should be testable.

No comments: