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
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:
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.