ASP.NET MVC Tip #25 – Unit Test Your Views without a Web Server

In this tip, I demonstrate how you can unit test ASP.NET MVC views without running a Web server. I show you how to unit test views by creating a custom MVC View Engine and a fake Controller Context.

The more of your web application that you can test, the more confident that you can be that changes to your application won’t introduce bugs. ASP.NET MVC makes it easy to create unit tests for your models and controllers. In this tip, I explain how you also can unit test your views.

Creating a Custom View Engine

Let’s start by creating a custom View Engine. Listing 1 contains the code for a really simple View Engine named, appropriately enough, the SimpleViewEngine.

Listing 1 – SimpleViewEngine.vb (VB.NET)

Imports System
Imports System.IO
Imports System.Text.RegularExpressions
Imports System.Web
Imports System.Web.Mvc
 
Namespace Tip25
    Public Class SimpleViewEngine
        Implements IViewEngine
 
        Private _viewsFolder As String = Nothing
 
        Public Sub New()
            If HttpContext.Current IsNot Nothing Then
                Dim root = HttpContext.Current.Request.PhysicalApplicationPath
                _viewsFolder = Path.Combine(root, "Views")
 
            End If
        End Sub
 
        Public Sub New(ByVal viewsFolderPhysicalPath As String)
            _viewsFolder = viewsFolderPhysicalPath
        End Sub
 
        Public Sub RenderView(ByVal viewContext As ViewContext) Implements IViewEngine.RenderView
            If _viewsFolder Is Nothing Then
                Throw New NullReferenceException("You must supply a viewsFolder path")
            End If
            Dim fullPath As String = Path.Combine(_viewsFolder, viewContext.ViewName) & ".htm"
            If (Not File.Exists(fullPath)) Then
                Throw New HttpException(404, "Page Not Found")
            End If
 
            ' Load file
            Dim rawContents As String = File.ReadAllText(fullPath)
 
            ' Perform replacements
            Dim parsedContents As String = Parse(rawContents, viewContext.ViewData)
 
            ' Write results to HttpContext
            viewContext.HttpContext.Response.Write(parsedContents)
        End Sub
 
        Public Function Parse(ByVal contents As String, ByVal viewData As ViewDataDictionary) As String
            Return Regex.Replace(contents, "\{(.+)\}", Function(m) GetMatch(m, viewData))
        End Function
 
        Protected Overridable Function GetMatch(ByVal m As Match, ByVal viewData As ViewDataDictionary) As String
            If m.Success Then
                Dim key As String = m.Result("$1")
                If viewData.ContainsKey(key) Then
                    Return viewData(key).ToString()
                End If
            End If
            Return String.Empty
        End Function
 
    End Class
End Namespace

Listing 1 – SimpleViewEngine.cs (C#)

using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Mvc;
 
namespace Tip25
{
    public class SimpleViewEngine : IViewEngine
    {
        private string _viewsFolder = null;
 
        public SimpleViewEngine()
        {
            if (HttpContext.Current != null)
            {
                var root = HttpContext.Current.Request.PhysicalApplicationPath;
                _viewsFolder = Path.Combine(root, "Views");
 
            }
        }
 
        public SimpleViewEngine(string viewsFolderPhysicalPath)
        {
            _viewsFolder = viewsFolderPhysicalPath;
        }
 
        public void RenderView(ViewContext viewContext)
        {
            if (_viewsFolder == null)
                throw new NullReferenceException("You must supply a viewsFolder path");
            string fullPath = Path.Combine(_viewsFolder, viewContext.ViewName) + ".htm";
            if (!File.Exists(fullPath))
                throw new HttpException(404, "Page Not Found");
 
            // Load file
            string rawContents = File.ReadAllText(fullPath);
 
            // Perform replacements
            string parsedContents = Parse(rawContents, viewContext.ViewData);
            
            // Write results to HttpContext
            viewContext.HttpContext.Response.Write(parsedContents);
        }
 
        public string Parse(string contents, ViewDataDictionary viewData)
        {
            return Regex.Replace(contents, @"\{(.+)\}", m => GetMatch(m, viewData));
        }
 
        protected virtual string GetMatch(Match m, ViewDataDictionary viewData)
        {
            if (m.Success)
            {
                string key = m.Result("$1");
                if (viewData.ContainsKey(key))
                    return viewData[key].ToString();
            }
            return String.Empty;
        }
 
 
    }
}

Notice that the SimpleViewEngine implements the IViewEngine interface. This interface has one method that you must implement: RenderView().

In Listing 1, the RenderView() method loads a file from disk and replaces tokens in the file with items from ViewData. Listing 2 contains a controller that uses the SimpleViewEngine. When you call the HomeController.Index() action, the action returns a view named Index.

Listing 2 – HomeController.vb (VB.NET)

Imports Tip25.Tip25
 
<HandleError()> _
Public Class HomeController
    Inherits System.Web.Mvc.Controller
 
    Public Sub New()
        Me.ViewEngine = New SimpleViewEngine()
    End Sub
 
 
    Public Function Index() As ActionResult
        ViewData("Message") = "Welcome to ASP.NET MVC!"
        ViewData("Message2") = "Using a custom View Engine"
 
        Return View("Index")
    End Function
 
End Class

Listing 2 – HomeController.cs (C#)

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
 
namespace Tip25.Controllers
{
    [HandleError]
    public class HomeController : Controller
    {
        public HomeController()
        {
            this.ViewEngine = new SimpleViewEngine();
        }
 
 
        public ActionResult Index()
        {
            ViewData["Message"] = "Welcome to ASP.NET MVC!";
            ViewData["Message2"] = "Using a custom View Engine";
 
            return View("Index");
        }
 
    }
}

The Index view is contained in Listing 3. Notice that the name of the file is Index.htm. The SimpleViewEngine returns .htm files.

Listing 3 – Index.htm

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" >
<head>
    <title>Tip 25</title>
</head>
<body>
    
    Here is the first message:
    {message}
 
    <br />
    Here is the second message:
    <b>{message2}</b>
 
</body>
</html>

The Index view contains tokens marked with opening and closing curly braces. The SimpleViewEngine.RenderView() method replaces each token with an item from View Data that has the same name. When the Index view is rendered by the SimpleViewEngine, you get the page in Figure 1.

Figure 1 - Page rendered from Index view

image

Creating a Fake Controller Context

The SimpleViewEngine.RenderView() method does not return a value. Instead, the RenderView() method writes directly to the HttpContext.Response object (the HttpResponse object).Therefore, in order for us to unit test our views, we must be able to fake the HttpContext object so that we can spy on the values added to this object.

In two of my earlier tips, I demonstrated how to fake the ControllerContext and HttpContext objects:

http://weblogs.asp.net/stephenwalther/archive/2008/06/30/asp-net-mvc-tip-12-faking-the-controller-context.aspx

http://weblogs.asp.net/stephenwalther/archive/2008/07/02/asp-net-mvc-tip-13-unit-test-your-custom-routes.aspx

In earlier tips, I demonstrated how faking the ControllerContext and HttpContext objects is useful when you need to build unit test things like Session State, Cookies, Form fields, and Route Tables.

The code download at the end of this tip includes a project named MvcFakes. I’ve added a fake HttpResponse object to the set of fake objects that you can use within your unit tests.

Creating a Unit Test for a View

Now that we have created a custom View Engine and we have created a set of fakes, we can unit test our views. The test class in Listing 4 tests the Index view returned by the HomeController.Index() action.

Listing 4 – HomeControllerTest.vb (VB.NET)

Imports System
Imports System.Collections.Generic
Imports System.Text
Imports System.Web.Mvc
Imports Microsoft.VisualStudio.TestTools.UnitTesting
Imports MvcFakes
Imports System.Web.Routing
Imports Tip25
Imports Tip25.Tip25
 
<TestClass()> Public Class HomeControllerTest
 
    Private Const viewsPath As String = "C:\Users\swalther\Documents\Common Content\Blog\Tip25 Custom View Engine\CS\Tip25\Tip25\Views"
 
    <TestMethod()> _
    Public Sub Index()
        ' Setup controller
        Dim controller As New HomeController()
        controller.ViewEngine = New SimpleViewEngine(viewsPath)
 
        ' Setup fake controller context
        Dim rd = New RouteData()
        rd.Values.Add("controller", "home")
        Dim fakeContext = New FakeControllerContext(controller, rd)
 
        ' Execute
        Dim result As ViewResult = TryCast(controller.Index(), ViewResult)
        result.ExecuteResult(fakeContext)
        Dim page As String = fakeContext.HttpContext.Response.ToString()
 
        ' Verify
        StringAssert.Contains(page, "<title>Tip 25</title>")
        StringAssert.Contains(page, "Welcome to ASP.NET MVC!", "Missing Message")
        StringAssert.Contains(page, "<b>Using a custom View Engine</b>", "Missing Message2 with bold")
    End Sub
End Class

Listing 4 – HomeControllerTest.cs (C#)

using System.Web.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MvcFakes;
using Tip25;
using Tip25.Controllers;
using System.Web.Routing;
 
namespace Tip25Tests.Controllers
{
    
    [TestClass]
    public class HomeControllerTest
    {
        private const string viewsPath =
            @"C:\Users\swalther\Documents\Common Content\Blog\Tip25 Custom View Engine\CS\Tip25\Tip25\Views";
 
        [TestMethod]
        public void Index()
        {
            // Setup controller
            HomeController controller = new HomeController();
            controller.ViewEngine = new SimpleViewEngine(viewsPath);
 
            // Setup fake controller context
            var routeData = new RouteData();
            routeData.Values.Add("controller", "home");
            var fakeContext = new FakeControllerContext(controller, routeData);
 
            // Execute
            ViewResult result = controller.Index() as ViewResult;
            result.ExecuteResult(fakeContext);
            string page = fakeContext.HttpContext.Response.ToString();
 
            // Verify
            StringAssert.Contains(page, "<title>Tip 25</title>");
            StringAssert.Contains(page, "Welcome to ASP.NET MVC!", "Missing Message");
            StringAssert.Contains(page, "<b>Using a custom View Engine</b>", "Missing Message2 with bold");
        }
 
    }
}

The test in Listing 4 contains four sections. The first section prepares the HomeController class by associating our custom SimpleViewEngine with the class. Notice that you must provide a hard coded path to the views folder to the constructor of the SimpleViewEngine. (You’ll need to fix this path when using the code download at the end of this tip)

The second section prepares the fake ControllerContext object. Notice that you must pass a controller name to the constructor for the FakeHttpContext class.

Next, the HomeController.Index() action is called. This action returns a ViewResult. The ViewResult is then executed with the fake HttpContext. Finally, the HttpResponse.ToString() method is called to retrieve the content written to the HttpResponse object by the SimpleViewEngine.

The final section verifies that the page rendered by the view contains three substrings. First, the title of the HTML page is verified. Next, the existence of the two messages from the ViewData is verified. Notice that the test checks whether or not the second message was, in fact, rendered in bold.

Summary

In this tip, I’ve shown how you can extend your unit tests to cover your views. By taking advantage of a custom View Engine, you build unit tests for your models, controllers, and views.

Download the Code

10 Comments

Comments have been disabled for this content.