对ASP.NET MVC项目中的视图做单元测试

Tesia ·
更新时间:2024-09-20
· 814 次阅读

关于视图的单元测试

  说到ASP.NET MVC,我们似乎始终都在关注对于Controller的测试 -- -- 虽然Stephen Walther也写过如何脱离Web Server对View进行单元测试,但是他的方法可看而不可用。复杂的构造和预备,以及对生成的HTML字符串作判断 -- -- 这真是在对视图做单元测试吗?仔细分析他的代码可以发现,这其实是在对ViewEngine做单元测试。而且,如果真要对ViewEngine做单元测试,也不应该像他那样依赖外部文件。在我看来,他的做法什么都不是......似乎美观,似乎能博得一些"掌声",但是这个掌声是来自于他的解决方案,还是大家一时的冲动呢?

  如果要对视图做单元测试,还是要将内容呈现在浏览器中才行。在对网页做单元测试时,我们一般会使用WatiN等工具操作浏览器,打开页面,再对其DOM元素结构及内容作断言。不过......这是单元测试吗?可惜这只能算是一种回归测试或用户验收测试。因为,我们在打开一个页面的时候,从表现层到业务逻辑再到数据访问,应用程序的每个部件都在忙碌着。而单元测试讲究的是"分离",分离一切关注,分离一切依赖。因为分离,我们才能准确定位错误;因为分离,我们才能在测试中使用我们准备好的数据。

  既然要分离,我们必须遵循一定的使用规范。在《ASP.NET MVC单元测试佳实践》中我提到,在View中只能使用ViewData中的数据,而不该依赖其他内容(包括HttpContext)。这样我们可以自行构造ViewData并注入一个视图对象中。事实上,这个约定在ASP.NET MVC自带的项目模板中被破坏了。请看ViewsSharedLogOnUserControl.ascx,其中通过this.User来查看当前用户的登陆状态。这是个定义在传统Page对象上的属性,从当前HttpContext上直接获取。如果使用这种方式,我们在单元测试时难以"模拟"当前用户的登陆状态,进而难以使测试覆盖到测试的各种情况了。

Lightweight Test Automation Framework

  在这里,老赵推荐使用ASP.NET Team提供的Lightweight Test Automation Framework(下文称之为LTAF)作为测试工具,它目前已经在CodePlex上更新至Feb Update版本。这个框架的作用与WatiN和Selenium类似,可操作浏览器对应用程序编写回归测试。虽然在某些方面(例如DOM元素的选取)不如"竞争对手",但是LTAF自有其独到之处:

  由于直接在浏览器中运行,它天生便支持现有的 -- -- 以及未来可能出现的任意浏览器。

  由于直接部署在被测试的网站中,因此测试代码和网站页面是在同一个进程中。

  第一点优势自不必说,而第二点更是关键。试想WatiN和Selenium,都是通过编写代码在浏览器中打开页面。这意味着我们的在测试代码和被测试的网页分别在不同的进程中。在这个前提下,如果我们要将测试代码中定义的数据传递给被测试的网页(也是视图对象),我们必须进行跨进程的通信。而无论怎么实现,都逃不过"序列化"一途,这无疑增加了复杂度。而使用LTAF之后,这个问题瞬间烟消云散了,因为我们可以直接在内存中"传递"测试数据,一切都只是个引用而已。

  不过任何事物都具有两面性,LTAF也有一些难以天生的,而且是永远无法弥补的缺点。例如:

  由于LTAF将待测试的页面放置在Frame中,因此该页面上的window.top等基于浏览器frame结构的属性会被改变。

  由于LTAF的本质是使用JavaScript来操作DOM,这意味着任何会阻塞程序进行的操作(例如alert)都不能使用,否则将阻塞整个测试过程。

  不过幸运的是,这两点都不回成为严重的问题。对于第一种,我们只需要编写一个自定的getTop方法来替换直接访问windows.top的做法即可。而第二种情况 -- -- 老赵从来不喜欢alert或confirm这种"纯浏览器功能",因为它们会带来很差的用户体验,更何况现在的JavaScript类库/框架都能很轻松的做出这种效果,您觉得呢?

  LTAF的具体使用方式可参考其Release Note。令人奇怪的是,老赵发现直接在项目中使用LTAF会有一些小问题(不过它的示例为什么一切正常呢?),因此进行了一些细微的修改。请注意~UnitViewDriverPage.aspx文件尾部的一些JavaScript代码。

UnitView的使用

  于是老赵编写了一个组件UnitView,方便我们构造一个单元测试时所需的数据。有了数据,便能够直接将视图在浏览器中加以呈现了。例如: 

[WebTestClass] public class HomeTests {     [WebTestMethod]     public void LoggedOnIndexTest()     {         var data = new TestViewData<IndexModel>         {             ControllerName = "Home",             ActionName = "Index",             Model = new IndexModel             {                 Message = "Welcome guys!",                 Identity = new UserIdentity                 {                     IsAuthenticated = true,                     Name = "Jeffrey Zhao"                 }             }         };           HtmlPage page = new HtmlPage(TestViewData.GenerateHostUrl(data));           // Assert title         Assert.AreEqual("Home Page", page.Elements.Find("title", 0).GetInnerText());           // Assert head element         var mainContent = page.Elements.Find("main");         var head2 = mainContent.ChildElements.FindAll("h2").Single();         Assert.AreEqual(data.Model.Message, head2.GetInnerText(), "Message should be displayed.");           var loginTabInnerText = page.Elements.Find("logindisplay").GetInnerTextRecursively();         Assert.IsTrue(loginTabInnerText.Contains("Welcome"), "'Welcome' missed.");         Assert.IsTrue(loginTabInnerText.Contains(data.Model.Identity.Name), "Login name missed.");     } }   自然,Web Server是不可或缺的。幸运的是,分离让我们的视图只会涉及简单的测试数据,这样VS自带的简单Web Server足够了。在上面的代码中,我们直接构造了强类型的TestViewData对象,它包含呈现一个视图所需要的所有数据:

Cotroller和Action名称。从理论上说,由不同的Controller和Action进入同样的视图可能会得到不同的结果。 View和Master名称。如果省略,则表明将使用默认的视图,即通过Controller和Action的值来确定。 ViewData和Model。   TestViewData.GenerateHostUrl方法会把data保存起来,并返回一个URL。访问该URL便能够得到对应的视图内容。

  如果您想使用UnitView,可以从上面的链接中下载UnitView的源代码和示例在本机进行尝试。使用UnitView时主要有以下几个注意点:

将Tests项目的输出路径指向被测试网站的bin目录,这样既可以在运行时得到正确的程序集,又不必为网站添加多余的引用。 将~UnitView目录复制到您的网站根目录下(在发布网站时,请剔除该目录)。如果想使用其它目录,请关注接下来UnitView实现分析。 编辑~UnitViewWeb.config文件,将MvcApp.Tests.dll修改为您自己的包含测试代码的程序集。 UnitView实现分析

  UnitView组件非常简单,简单地几乎不值一提。TestViewData类型包含了测试需要的所有数据,而TestViewData<TModel>继承了TestViewData,提供了强类型的Model属性访问方式。它们不作分析了。

  此外,TestViewData还有一些静态方法:

public class TestViewData {     static TestViewData()     {         PersistentProvider = new InProcPersistentProvider();     }       public static IPersistentProvider PersistentProvider { get; set; }       public static string GenerateHostUrl(TestViewData data)     {         var key = PersistentProvider.Save(data);         return ViewHostHandlerUrl + "?key=" + HttpUtility.UrlEncode(key);     }       private static string ViewHostHandlerUrl     {         get         {             return ConfigurationManager.AppSettings["UnitView_ViewHostHandlerUrl"]                 ?? "/UnitView/ViewHostHandler.ashx";         }     }       internal static TestViewData Load(string key)     {         return PersistentProvider.Load(key);     }     ... }   GenerateHostUrl 方法将委托PersistentProvider 保存对象,并得到一个key 。这个key 将拼接在ViewHostHandlerUrl 属性上,这便是被测试的路径。从代码中可以看出,如果您不想使用默认的测试路径,只需在web.config 的AppSettings 节点中添加一个目标地址即可。

  PersistentProvider 属性为IPersistentProvider 接口类型,其中定义了Save/Load/Remove 三个方法。IPersistentProvider 在项目中只有一个实现:InProcPersistentProvider ,它会将TestViewData 存放在内存中的一个字典里。这个实现已经足够让UnitView 结合LTAF 运行(LTAF 的同进程特性起到了关键的作用)。不过,如果您还是希望使用WatiN 等独立进程的测试工具,必须实现自己的IPersistentProvider 类型。例如您可以实现一个FilePersistentProvider ,将TestViewData 序列化至一个外部文件中,这样可以在合适的时候将它取回了。

  另一个较为关键的类型是UnitView.Engine.ViewHostHandler :

public class ViewHostHandler : IHttpHandler {     private HttpContext Context { get; set; }       public void ProcessRequest(HttpContext context)     {         this.Context = context;           ControllerContext controllerContext = new ControllerContext(             new HttpContextWrapper(context),             this.Data.RouteData,             new MockController());           new ViewResult         {             MasterName = this.Data.MasterName,             ViewName = this.Data.ViewName,             TempData = this.Data.TempData,             ViewData = this.Data.ViewData,         }.ExecuteResult(controllerContext);     }       private string Key     {         get         {             string key = this.Context.Request.QueryString["key"];             if (String.IsNullOrEmpty(key))             {                 throw new ArgumentNullException("key");             }               return key;         }     }       private TestViewData m_data;     private TestViewData Data     {         get         {             if (this.m_data == null)             {                 this.m_data = TestViewData.Load(this.Key);                 if (this.m_data == null)                 {                     throw new ArgumentNullException("Cannot retrieve the data.");                 }             }               return this.m_data;         }     }       public bool IsReusable { get { return false; } } }   首先,在ProcessRequest 方法会取回TestViewData ,并根据这些数据构造一个ViewResult 对象,后执行它的ExecuteResult 方法来输出视图内容。由于ExecuteRequest 方法的需要,我们还必须构造一个ControllerContext 对象,也意味着我们还必须提供一个Controller 对象和HttpContext 的封装。从代码中可以看出,我们这里使用了简单的数据。由于视图遵守“ 约定” ,它只会从ViewData 中获取数据,所以无论Controller 或HttpContext 是什么值都已经无关紧要了。

  您可能会想,为什么会有这样的“ 约定” ,不让视图从HttpContext 对象中获取数据呢?Mock 一个HttpContext 对象也不是那么困难(这里要感谢各种强大的Mock 框架)啊。可惜,Mock 后的HttpContext 很难进行序列化,这样几乎杜绝了跨进程通信的可能,这对于使用WatiN 和Selenium 进行测试的朋友们无疑是一种灾难。权衡之下,老赵决定放弃对HttpContext 的支持。



ASP.NET 视图 MVC 单元测试 测试 net ASP asp.net mvc 单元

需要 登录 后方可回复, 如果你还没有账号请 注册新账号