ASP.NET MVC Tip #25 – 单元测试 View 不使用 Web Server
ASP.NET MVC Tip #25 – Unit Test Your Views without a Web Server
美语原文:http://weblogs.asp.net/stephenwalther/archive/2008/07/26/asp-net-mvc-tip-25-unit-test-your-views-without-a-web-server.aspx
国语翻译:http://www.cnblogs.com/mike108mvp
译者注:在下水平有限,翻译中若有错误或不妥之处,欢迎大家批评指正。谢谢。
译者注:ASP.NET MVC QQ交流群 1215279 欢迎对 ASP.NET MVC 感兴趣的朋友加入
在这篇帖子中,我将演示如何单元测试ASP.NET MVC views 而无需运行一个Web server。我将展示如何通过创建一个自定义的MVC View Engine和一个伪造的Controller Context来单元测试view。
你的web application可以测试的部分越多,你就对应用程序的修改不会带来新的bug越有信心。ASP.NET MVC让你很容易测试models 和controllers。在这篇帖子中,我将解释如何单元测试views。
创建一个自定义View Engine
让我们从创建一个自定义的View Engine开始。Listing 1中包含了一个叫做SimpleViewEngine的非常简单的View Engine。
Listing 1 – SimpleViewEngine.cs (C#)
namespace Tip25
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ContractedBlock.gif)
{
public class SimpleViewEngine : IViewEngine
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
private string _viewsFolder = null;
![](/Images/OutliningIndicators/InBlock.gif)
public SimpleViewEngine()
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
if (HttpContext.Current != null)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
var root = HttpContext.Current.Request.PhysicalApplicationPath;
_viewsFolder = Path.Combine(root, "Views");
![](/Images/OutliningIndicators/InBlock.gif)
}
}
![](/Images/OutliningIndicators/InBlock.gif)
public SimpleViewEngine(string viewsFolderPhysicalPath)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
_viewsFolder = viewsFolderPhysicalPath;
}
![](/Images/OutliningIndicators/InBlock.gif)
public void RenderView(ViewContext viewContext)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
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");
![](/Images/OutliningIndicators/InBlock.gif)
// Load file
string rawContents = File.ReadAllText(fullPath);
![](/Images/OutliningIndicators/InBlock.gif)
// Perform replacements
string parsedContents = Parse(rawContents, viewContext.ViewData);
// Write results to HttpContext
viewContext.HttpContext.Response.Write(parsedContents);
}
![](/Images/OutliningIndicators/InBlock.gif)
public string Parse(string contents, ViewDataDictionary viewData)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
return Regex.Replace(contents, @"\{(.+)\}", m => GetMatch(m, viewData));
}
![](/Images/OutliningIndicators/InBlock.gif)
protected virtual string GetMatch(Match m, ViewDataDictionary viewData)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
if (m.Success)
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
string key = m.Result("$1");
if (viewData.ContainsKey(key))
return viewData[key].ToString();
}
return String.Empty;
}
![](/Images/OutliningIndicators/InBlock.gif)
}
}
![](/Images/OutliningIndicators/None.gif)
注意SimpleViewEngine 实现了IViewEngine 接口。该接口有一个RenderView()方法必须实现。
在Listing 1中,RenderView()方法从硬盘中加载一个文件,并用ViewData中的项目来替换文件中的标签(tokens)。 在Listing 2中,包含了一个使用SimpleViewEngine的 controller 。当你调用HomeController.Index() action时,它返回一个叫做Index的view。
Listing 2 – HomeController.cs (C#)
namespace Tip25.Controllers
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ContractedBlock.gif)
{
[HandleError]
public class HomeController : Controller
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
public HomeController()
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
this.ViewEngine = new SimpleViewEngine();
}
![](/Images/OutliningIndicators/InBlock.gif)
![](/Images/OutliningIndicators/InBlock.gif)
public ActionResult Index()
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
ViewData["Message"] = "Welcome to ASP.NET MVC!";
ViewData["Message2"] = "Using a custom View Engine";
![](/Images/OutliningIndicators/InBlock.gif)
return View("Index");
}
![](/Images/OutliningIndicators/InBlock.gif)
}
}
Index view在Listing 3中。注意文件名是Index.htm。SimpleViewEngine 返回了一个.htm 文件。
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:
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
{message}
![](/Images/OutliningIndicators/None.gif)
<br />
Here is the second message:
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
<b>
{message2}</b>
![](/Images/OutliningIndicators/None.gif)
</body>
</html>
![](/Images/OutliningIndicators/None.gif)
Index view包含也以前后大括号为标记的标签。SimpleViewEngine.RenderView()方法用View Data中的项目来替换每个同名标签。当Index view被SimpleViewEngine呈现时,你得到了Figure 1中的页面。
Figure 1 - Index view呈现的页面
![](http://weblogs.asp.net/blogs/stephenwalther/WindowsLiveWriter/ASP.NETMVCTip25UnitTestYourViewswithouta_8958/image_thumb.png)
创建一个伪造的Controller Context
SimpleViewEngine.RenderView()方法并不返回一个值。RenderView()方法将值直接写入HttpContext.Response对象中。因此,为了单元测试views,我们必须能够伪造HttpContext 对象,以便我们能够暗中监视添加到该对象中的值。
在我以前的两篇帖子中,我演示了如何伪造ControllerContext 和HttpContext 对象:
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
在以前的这些帖子中,我演示了伪造ControllerContext 和 HttpContext对象是很有用的,当你需要单元测试Session State, Cookies, Form fields, 和 Route Tables时。
本文结尾处下载的代码中包含一个MvcFakes项目。我已经添加了一个伪造的HttpResponse 对象到一批伪造对象中,你可以在单元测试中使用这些伪造对象。
为View创建一个单元测试
既然我们已经创建了一个自定义的View Engine和一批伪造对象,那么我们就可以单元测试view了。Listing 4中的测试类测试了HomeController.Index() action返回的Index view。
Listing 4 – HomeControllerTest.cs (C#)
![](/Images/OutliningIndicators/None.gif)
namespace Tip25Tests.Controllers
![](/Images/OutliningIndicators/ExpandedBlockStart.gif)
![](/Images/OutliningIndicators/ContractedBlock.gif)
{
[TestClass]
public class HomeControllerTest
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
private const string viewsPath =
@"C:\Users\swalther\Documents\Common Content\Blog\Tip25 Custom View Engine\CS\Tip25\Tip25\Views";
![](/Images/OutliningIndicators/InBlock.gif)
[TestMethod]
public void Index()
![](/Images/OutliningIndicators/ExpandedSubBlockStart.gif)
{
// Setup controller
HomeController controller = new HomeController();
controller.ViewEngine = new SimpleViewEngine(viewsPath);
![](/Images/OutliningIndicators/InBlock.gif)
// Setup fake controller context
var routeData = new RouteData();
routeData.Values.Add("controller", "home");
var fakeContext = new FakeControllerContext(controller, routeData);
![](/Images/OutliningIndicators/InBlock.gif)
// Execute
ViewResult result = controller.Index() as ViewResult;
result.ExecuteResult(fakeContext);
string page = fakeContext.HttpContext.Response.ToString();
![](/Images/OutliningIndicators/InBlock.gif)
// 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");
}
![](/Images/OutliningIndicators/InBlock.gif)
}
}
Listing 4中的测试代码包含4个部分。第一部分通过将我们自定义的SimpleViewEngine 和HomeController 类联系起来,进行准备HomeController。注意你必须给SimpleViewEngine的构造函数提供一个硬编码的 view目录的路径。(下载本文的代码后,你需要修改这个路径)
第二部分准备伪造的ControllerContext 对象。注意你必须传递一个controller name 给FakeHttpContext 类的构造函数。
接着,HomeController.Index() action方法被调用。这个action 返回一个ViewResult。接着,该ViewResult 在伪造的HttpContext中被执行。最后,HttpResponse.ToString() 方法被调用来获得SimpleViewEngine写入HttpResponse 对象的值。
最后一部分证实view 呈现的页面包含三个子字符串。第一个是HTML 页面的title 被证实。第二,证实存在来自ViewData 的两个messages。注意该测试要证实第2个message 是否是粗体显示。
总结
在这篇帖子中,我展示了如何扩展你的单元测试来覆盖views。通过利用自定义的View Engine,你可以为models, controllers, 和 views创建单元测试。
下载代码:http://weblogs.asp.net/blogs/stephenwalther/Downloads/Tip25/Tip25.zip