Adventures with TDD part 3: Testing MVC views

Views are hard to test in MVC

I've been trying to write ASP.NET MVC code using Test Driven Development (TDD) principles. This has been going very well for the Models and Controllers of MVC, but I have been drawing a blank at the Views. It turns out that writing unit tests for views in ASP.NET MVC is hard. There simply are too many hidden dependencies on the ASP.NET environment built into the view engine to be able to build standalone tests around it.

A common response I have come across is that there shouldn't be any logic in the views, so there shouldn't be any need to write unit tests around them. This argument applies very well to views that are pure HTML which work from a strongly-typed view model, as in that case it should be sufficient to test that the appropriate view model has been created and populated with the correct values. However once you start introducing AJAX functionality into your application then the nice separation between view and controller logic starts to disappear.

Unit testing Javascript is possible with a variety of options, but these all seem to involve running a Javascript test runner from within a HTML page, which has two problems:

  1. It starts to blur the line between test and production code, as the tests need to be run from within the same web project in order to have access to the script files used in the views;
  2. It becomes more inconvenient to run the tests: having to fire up a browser in order to run the automated tests for a part of the application creates an extra step and an extra psychological barrier to running the tests often. Personally, I'd like all my tests to be run from one place, ideally from within Visual Studio.

In addition, I want to be able to test the views on their own rather than having to run the whole system. Automated UI testing (using something like Selenium) is great for integration testing, but I want to be able to test the view logic in isolation from the rest of the application.

A possible solution

I think I have found a way get around this problem. Essentially what I am doing is dynamically generating the HTML of a view from the view path and view model. I can then use headless browser automation to execute the view using a virtual web browser, clicking on links and running scripts dynamically. This allows me to completely decouple my view logic from the controller logic, allowing me to set up views on demand without having to do things like access the database. I doubt I am the first to try something like this, but I haven't been able to find anything online to do view testing the way I would like, at least not in ASP.NET MVC. However, this could mean that I am going about this completely the wrong way, or that I'm just not very good at using Google!

Dynamically generating view HTML

After many hours of frustration, I have been forced to admit that any attempt to try to use ASP.NET MVC's inbuilt view engine from outside the ASP.NET environment (i.e. from within a test project) is doomed to failure. I tried several things, including attempting to mock the ControllerContext, and although I was able to get this working from within the MVC project, I was unable to get this approach to work from within a standalone project. There simply are too many hidden dependencies for this to work.

Instead, I tried a different approach: David Ebbo's Razor Generator. This was a lot more successful, however I encountered two main difficulties in trying to use it:

  1. Razor Generator is available as a Visual Studio extension, but as I don't own a full copy of Visual Studio on my home machine, I needed to get this to work with Visual Web Developer. I was able to accomplish this by changing the extension of the .vsix file .zip, opening it in 7Zip, and editing the <SupportedProducts> section of the extension.vsixmanifest file to read:
        <VisualStudio Version="10.0">		
    I was then able to rename save it back as a .vsix file and open it in Visual Web Developer. This installed the extension without any problems as far as I can tell (Warning: do this at your own risk!)
  2. Razor Generator, as it stands, generates the HTML for each view, partial view and layout view separately, but I needed a way to render the whole page for a view, including the layout view and any partial views. To do this I had to download the source code and edit the PrecompiledMvcViews.Testing project. As it stands, it generates a placeholder value for any partial views referenced from the view page, so I modified this to include the generated HTML for each partial view instead. I also had to add some additional code to embed the view HTML within the generated HTML of its layout view.

Headless browser automation

Once I had the view HTML, I was able to use headless browser automation against it using the HTMLUnit library. This is a Java library, but as it the source is freely available I was able to compile it as a .NET dll using IKVM (using the approach described by Steven Sanderson), allowing me to use it in my test project. So if I have a view as follows:

@model string

    ViewBag.Title = "Test";
    Layout = "~/Views/Shared/_Layout.cshtml";

<a href="#" id="linkWithJavascriptClickHandlerAttached">test</a>

<div id="elementChangedByClickHandler">@Model</div>

<script type="text/javascript">
    $(document).ready(function () {
        $('#linkWithJavascriptClickHandlerAttached').click(function () {
            $.getJSON('/Home/ActionReturningJsonResult/', function (data) {
Contents of /Views/Shared/_Layout.cshtml (includes link to jQuery):
<!DOCTYPE html>	
    <meta charset="utf-8" />
    <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
    <script src="@Url.Content("~/Scripts/jquery-1.5.1.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/modernizr-1.7.min.js")" type="text/javascript"></script>
I can test that the click handler successfully sends an AJAX request to the action "/Home/ActionReturningJsonResult/", and that the HTML of the div with id "elementChangedByClickHandler" is set to the JSON value returned by that action like this (I'm using
public void ClickingOnLinkSetsElementHtmlToJsonContentFromServer()
    var mvcAssembly = typeof(MvcProject.Views.Home.Test).Assembly;
    var mvcRootFilePath = "C:\\Path\\To\\MvcProject";
    var viewTester = MvcViewTestHelperFactory.CreateHelper(mvcAssembly, mvcRootFilePath);

    var controllerToTest = "Home";
    var actionToTest = "Test";
    var actionRequestedViaAjax = "ActionReturningJsonResult";
    var jsonResultToReturn = "{ \"NewContent\": \"Content passed via AJAX request\" }";

    // tell the virtual web client to return a specific response for a specified controller/action combination
    // (needs extending to include routeData and specify response headers, status codes etc)
    viewTester.SetResponseContentForAction(controllerToTest, actionRequestedViaAjax, jsonResultToReturn);

    var viewModel = "Content passed via view model";

    // grab the HTML for the view we're interested in (also needs to handle routeData)
    HtmlPage page = viewTester.GetHtmlPageForView(controllerToTest, actionToTest, viewModel);

    // verify the view renders using the view model
    Assert.Equal("Content passed via view model", page.getElementById("elementChangedByClickHandler").asText());


    // verify the correct action was requested
    Assert.Contains("/Home/ActionReturningJsonResult", viewTester.GetRequestsMade());

    // verify the click handler got the correct JSON and changed the HTML of an element on the page
    Assert.Equal("Content passed via AJAX request", page.getElementById("elementChangedByClickHandler").asText());

Obviously it's still a work in progress: it doesn't handle routeData yet among other things, and some of the internals are a bit hacky at the moment, but if anyone's interested you can download the source from bitbucket and have a play; any feedback is appreciated, positive or negative.


There are no comments yet.

Contribute your words of wisdom

Don't just stand there ... say something!

Sorry, I couldn't add your comment just yet, please check the following things:

*denotes a required field