我们继续《ASP.NET MVC单元测试最佳实践》,今天主要谈论HttpContext的依赖问题。
在ASP.NET中进行单元测试的天敌便是HttpContext,它是ASP.NET的核心,极端复杂,却无法进行Mock1——可见微软能够写出那么庞大的ASP.NET框架真不那么容易。现在这个状况改善了不少,因此大家已经可以使用System.Web.Abstractions.dll了,这个程序集中提供了对于HttpContext的抽象,也就是HttpContextBase抽象类。因此在ASP.NET MVC中,各种组件均依赖于HttpContextBase而不是HttpContext。这是一个优秀的做法,大家以后可以尽可能地摆脱HttpContext了。
不过这似乎又是一个悖论。虽然已经可以对HttpContext进行Mock(这点增强了可测试性),但是过度依赖HttpContext对于单元测试来说也是一个伤害。这是HttpContext对象的天性所致:它实在太复杂了。您应该已经察觉到,这是个集万千宠爱于一身的对象,从请求,回复,应用程序,缓存……几乎包含了Web应用程序需要的所有信息。如果要测试一个依赖于HttpContext的方法,您势必要为HttpContext的Mock对象填充各种信息——其复杂程度视业务而定。而且,Mock关注的是“行为”,也就是说它关注的是做一件事情所使用“路径”。那么如果做一件事情可以采用多个路径又会怎样?是否需要在测试之前准备好所有的路径,并且验证被测试的代码“采用了,并仅仅采用了其中一条路径”?因此,Stub慢慢进入人们的视线。Stub关注的是“状态”……这就是另一个话题了,还会涉及到采用Record & Replay还是Arrange-Act-Assert方式来进行单元测试,暂且不提。
之前谈到对视图进行单元测试时,老赵曾经谈起在视图中应该只使用ViewData中的数据。这不是第一次说起要放弃HttpContext了,自从有了“抽象”这一有利武器后,一切“不和谐”因素都能够被分离。试想在MVP模式中,View和Presenter都使用各自的抽象进行交互,一切Web控件,HttpContext等对象都不复存在了,大家眼中只有“数据”和“模型”。同样,在ASP.NET MVC的Action方法中,也不应该使用HttpContext,这是基于良好的“可测试性”而考虑的。您可能会想,现在的HttpContextBase对象已经可以Mock了啊。没错,它的确“可以”,但是这样做会引起单元测试代码的膨胀,因为测试代码中的相当部分必须关注在测试数据的准备,而不是被测试的功能上。对于一个Action方法来说,它关注的应该是用户与业务逻辑的交互,而不是“如何把HTTP请求转化为可用的数据”。其实说到底,还是要“分离关注点”。
在ASP.NET MVC中负责“转化数据”的层次为Model Binder。关于这一点,现有的“示例”大都关注把Form或QueryString中的数据转化为Action参数上,不过Model Binder可用的地方其实更多。例如在《最佳实践》的代码中,原本AccountController的Delete方法实现如下:
public ActionResult Delete(string userName) { this.MiddleTier.UserManager.Delete(userName); Uri urlReferrer = this.Request.UrlReferrer; return this.Redirect(urlReferrer.ToString()); }
在删除了指定对象之后,页面将跳转到Url Referrer地址中。在上面的代码中,这个值将通过访问Request.UrlReferer来获得。这就使您的Action方法与HttpContext产生了依赖,因此它的单元测试代码就需要这样编写:
[TestMethod] public void DeleteTest() { string userName = "jeffz"; Uri urlReferrer = new Uri("http://www.microsoft.com"); var mockHttpContext = new Mock<HttpContextBase>(); mockHttpContext.Setup(c => c.Request.UrlReferrer).Returns(urlReferrer); var mockController = this.GetMockController(); mockController.Setup(c => c.MiddleTier.UserManager.Delete(userName)).Verifiable(); mockController.Object.ControllerContext = new ControllerContext( mockHttpContext.Object, new RouteData(), mockController.Object); mockController.Object.Delete(userName)... }
在单元测试代码中,我们Mock了一个HttpContextBase对象,让它的Request.UrlReferrer属性返回我们准备好的对象,再构造一个新的ControllerContext并交给Controller。而如果我们的UrlReferrer能够作为Delete方法的参数,那么单元测试代码就会一下子简单很多:
[TestMethod()] public void DeleteTest() { string userName = "jeffz"; Uri urlReferrer = new Uri("http://www.microsoft.com"); var mockController = this.GetMockController(); mockController.Setup(c => c.MiddleTier.UserManager.Delete(userName)).Verifiable(); mockController.Object.Delete(userName, urlReferrer)... }
有些朋友可能会问,不就是从Request的UrlReferrer属性中取值吗?我们为什么要构造一个ControllerContext,不能直接设置Controller对象吗?例如这样就简单多了:
mockController.Setup(c => c.Request.UrlReferrer).Returns(urlReferrer);
似乎可行,不过您运行的时候就会发现,框架会抛出异常,说只有接口的成员,或可以override的成员才能够被Mock。没错,Controller的Request属性不是virtual的,无法override。Controller类如此设计是故意的,目的就是限制了可用的路径。试想,如果您Mock了Controller.Request属性,但是程序代码通过Controller.HttpContext.Request进行访问又怎么办呢?类似的做法还有对方法重载的设计。一般来说,都会把其中几个方法委托给其中唯一的方法,而只有那个方法是可以被override的。这样在编写测试时,我们仅有的Mock入口便确定了,避免了测试代码过度了解方法实现的问题。
回到正题。如果要让Delete方法接urlReferrer受参数,那么我们就要编写Model Binder相关的组件:
public class UrlReferrerModelBinder : IModelBinder { public object BindModel( ControllerContext controllerContext, ModelBindingContext bindingContext) { return controllerContext.HttpContext.Request.UrlReferrer; } }
并使其可以直接运用到Action的参数上:
public class UrlReferrerAttribute : CustomModelBinderAttribute { private static UrlReferrerModelBinder s_modelBinder = new UrlReferrerModelBinder(); public override IModelBinder GetBinder() { return s_modelBinder; } }
于是乎,我们的Delete方法便可写为:
public ActionResult Delete(string userName, Uri urlReferrer) { this.MiddleTier.UserManager.Delete(userName); return this.Redirect(urlReferrer.ToString()); }
如今的代码,无论是应用程序还是框架类库,都必须考虑“可测试性”这个要求。例如.NET 3.0的WF,由于其可测试性不佳一直为人所诟病。现在我们在编写程序时,要时刻询问自己:“这么做方便测试吗?”考虑到这个问题,可能您就会放心地做出某些抉择了2。
注1:其实还是可以Mock的。例如Typemock使用Profiler的方式进行直接注入,可以Mock任何成员。不过,如果Moq等框架无法满足您的需要,一般便是您的设计有些问题了。
注2:例如,究竟让Action方法返回ActionResult,还是返回void,并直接通过Response输出呢?