在阅读本文之前,兄弟们请先注意两点:
关于可测试性的重要性,老赵觉得已经不需要再过多强调了。如果您想要获得高生产力,为代码编写单元测试似乎已经是必经之路了。不过可惜的是,ASP.NET应用程序给人的感觉,始终是对可测试性不太友好,其最重要的原因之一在于对HttpContext对象的高度依赖,而我们很难对HttpContext编写Mock或Stub:对于最常见的Mock框架来说,进行Mock的方式在于对抽象类型进行继承和重写,因此需要目标类型必须能够继承,其成员也必须能够重写(override),可惜HttpContext对这两个要求均不满足——虽然我们有TypeMock这个强大的工具,只可惜它是商业产品。而且事实上,如果Moq等框架无法满足您的要求,一般可以确定是设计有问题。从这个角度说,ASP.NET围绕HttpContext开展的一系列功能,在设计上的确有不足之处。
因此,为了提高ASP.NET应用程序的可测试性,各方都作了许多努力,其中的原则便是:尽可能减少对HttpContext的依赖(不可测试的逻辑),使逻辑依赖于特定的抽象类型。“特定”二字是指与您的业务或功能相关性,例如您在使用MVP模式进行开发时,使用的每个类型都是领域相关(如User),或界面相关(如SelectList)的抽象类型,而不是具体的界面(如DropDownList)或协议(HttpContext1)相关类型。这往往需要您在具体类型上多加一个抽象层,针对抽象进行编程。除了MVP模式之外,ASP.NET AJAX中的PageRequestManager也是如此,ScriptManager的各阶段操作都简单地委托给了PageRequestManager,这样不可测试的逻辑(ScriptManager)减少了,可以测试的逻辑(PageRequestManager)增加了。
不过可以想到的是,围绕HttpContext进行编程的场景也是不可避免的,例如Http Handler/Module等ASP.NET基础结构,亦或是连接HttpContext与抽象类型的“黏着剂”。关于这方面微软也在改进,例如随ASP.NET MVC发布了ASP.NET Abstraction,其中提供了抽象类型HttpContextBase(老赵个人不喜欢Base这样的后缀,其实更喜欢IHttpContext这样的接口类型),这是一个赤裸裸地抽象类,其中包含了HttpContext的所有成员,个个抽象。也正是由于这样的抽象,使得围绕HttpContext进行单元测试的可行性大大增加了。当然,这句话有个前提,那就是以前围绕HttpContext编写的代码,现在要使用HttpContextBase了,这也是提高ASP.NET应用程序可测试性的又一原则:对于一定要依赖HttpContext的逻辑,请依赖HttpContextBase。那么现在,兄弟们就随老赵来看一下,如何使用ASP.NET Abstraction来辅助ASP.NET开发。
HttpContext对象难以Mock,但是也并非说它的数据我们就无法“定制”,在某些“极端简单”的情况下,我们还是可以直接构造一个HttpContext对象进行测试的。比如下面这个毫无意义的Http Handler:
public class CountDataHandler : IHttpHandler { public bool IsReusable { get { return true; } } public void ProcessRequest(HttpContext context) { string data = context.Request.QueryString["data"]; if (data == null) { throw new ArgumentNullException("data"); } context.Response.Write(data.Length); }}
从Query String里获得data字段,如果没有该字段则抛出异常,如果有就输出它的长度。这个Handler的作用就是这么无聊,只是为了做一个简单的示例。那么对它的单元测试该怎么做呢?
[TestMethod] [ExpectedException(typeof(ArgumentNullException))] public void ProcessRequestTest_
Throw_ArgumentNullException_When_Data_Is_Empty() { HttpContext context = new HttpContext( new HttpRequest("test.aspx", "http://localhost/test.aspx", ""), new HttpResponse(new StringWriter())); CountDataHandler handler = new CountDataHandler(); handler.ProcessRequest(context); } [TestMethod] public void ProcessRequestTest_Check_Output() { string data = "Hello World"; TextWriter writer = new StringWriter(); HttpContext context = new HttpContext( new HttpRequest( "test.aspx", "http://localhost/test.aspx", "data=" + HttpUtility.UrlEncode(data)), new HttpResponse(writer)); CountDataHandler handler = new CountDataHandler(); handler.ProcessRequest(context); Assert.AreEqual(data.Length.ToString(),
writer.ToString(), "The output should be {0} but {1}.",
data.Length, writer.ToString()); }
它的单元测试分两种情况,一是在data字段缺少的情况下需要抛出异常(ExpectedException),二便是正常的输出。在测试的时候,我们通过HttpContext的一个构造函数创建对象,而这个构造函数会接受一个HttpRequest和一个HttpResponse对象。HttpRequest对象构造起来会接受文件名,路径和Query String;而HttpResponse构造时只需要一个TextWriter用于输出信息。由于我们这个场景过于简单,因此还真够用了。代码比较简单,意义也很明确,就不多作解释了。
不过很显然,这种简单场景是几乎无法遇到的。如果我们需要POST的情况呢?做不到;如果我们需要设置UserAgent呢?做不到;如果我们要检查Url Write的情况?做不到——统统做不到,真啥都别想做。因此我们还是无法使用这种方式进行测试,这第一个例子仅仅是为了内容“完整性”而加上的。
这个例子就复杂些了,并且直接来源于老赵以前的某个项目的代码——当然现在为了示例进行了简化和改造。在项目中我们往往要编写一些Handler来处理客户端的请求,而同时Handler需要对客户端进行身份验证及基于角色的授权,只有特定角色的客户才能访问Handler的主体逻辑,否则便抛出异常。而这样的逻辑有其固有的结构,因此我们这类Handler编写一个公用的父类,这样我们便可使用“模板方法”的形式来补充具体逻辑了。这个父类的实现如下:
public abstract class AuthorizedHandler : IHttpHandler { public bool IsReusable { get { return false; } } void IHttpHandler.ProcessRequest(HttpContext context) { this.ProcessRequest(new HttpContextWrapper(context)); } internal void ProcessRequest(HttpContextBase context) { if (!context.User.Identity.IsAuthenticated) { throw new UnauthorizedAccessException(); } foreach (var role in this.AuthorizedRoles) { if (context.User.IsInRole(role)) { this.ProcessRequestCore(context); return; } } throw new UnauthorizedAccessException(); } protected internal abstract void
ProcessRequestCore(HttpContextBase context); protected internal abstract IEnumerable<string>
AuthorizedRoles { get; } }
一般来说,我们会在IHttpHandler.ProcessRequest方法中进行逻辑实现,但是我们现在直接把方法调用转发给接受HttpContextBase作为参数的ProcessRequest方法重载。HttpContextBase是一个抽象类型,这便是我们的测试目标。这个方法首先判断用户是否经过认证,然后再将用户的角色,与AuthorizedRoles抽象属性中表示的合法角色进行匹配,如果匹配成功则调用ProcessRequestCore抽象方法,而无论是用户认证还是授权失败,都会抛出UnauthorizedAccessException异常。
这里有一个题外话:不知您是否注意到,这里没有private方法,所有的方法都有internal修饰。这么做的原因完全是为了进行单元测试。由于private方法无法被外部项目调用,因此我们只能使用internal作为修饰符,再为程序集加上InternalVisibleToAttribute标记,把所有的internal成员向测试项目开放。当然,此时程序集内部就能够随意调用那些方法了——还好,都是自家人,注意点便是了。
这段逻辑需要测试的环节比较多,我们依次看一下:
[TestMethod()] [ExpectedException(typeof(UnauthorizedAccessException))] public void ProcessRequestTest_Nonauthenticated_Request() { Mock<HttpContextBase> mockContext = new Mock
<HttpContextBase>(MockBehavior.Strict); mockContext.Setup(c => c.User.Identity.IsAuthenticated).
Returns(false); Mock<AuthorizedHandler> mockHandler = new Mock
<AuthorizedHandler> { CallBase = true }; mockHandler.Setup(h => h.ProcessRequestCore
(It.IsAny<HttpContextBase>())) .Throws(new Exception("ProcessRequestCore
should not be called.")); mockHandler.Setup(h => h.AuthorizedRoles) .Throws(new Exception("AuthorizedRoles
should not be accessed.")); mockHandler.Object.ProcessRequest(mockContext.Object); }
这是对没有通过身份验证的请求的回应,我们设置HttpContext.User.Identity.IsAuthenticated属性为false,并且声明不能碰触到ProcessRequestCore和AuthroizedRoles属性。在这样的情况下,我们自然期望抛出UnauthorizedAccessException。
[TestMethod()] [ExpectedException(typeof(UnauthorizedAccessException))] public void ProcessRequestTest_Nonauthorized_Request() { Mock<HttpContextBase> mockContext = new Mock
<HttpContextBase>(MockBehavior.Strict); mockContext.Setup(c => c.User.Identity.IsAuthenticated).
Returns(true); mockContext.Setup(c => c.User.IsInRole(It.IsAny<string>())) .Returns(false).Verifiable(); Mock<AuthorizedHandler> mockHandler = new Mock
<AuthorizedHandler> { CallBase = true }; mockHandler.Setup(c => c.ProcessRequestCore
(It.IsAny<HttpContextBase>())) .Throws(new Exception("ProcessRequestCore
should not be called.")); mockHandler.Setup(c => c.AuthorizedRoles) .Returns(new string[] { "admin", "user" })
.Verifiable(); try { mockHandler.Object.ProcessRequest(mockContext.Object); } catch { throw; } finally { mockContext.Verify(); mockHandler.Verify(); } }
这是测试身份验证通过,而基于角色的授权失败时的情况。我们把IsAuthenticated设为true,并且要求IsInRole方法在“接受到任何string类型参数”的时候都返回false,而最后再“象征性”地设置AuthorizedRoles所返回的内容。这个测试的期望是抛出UnauthorizedAccessException,不过值得注意的是,我们的代码还有其他要求,那就是要求IsInRole和AuthorizedRoles一定要调用过——您明白了吗?这就是为什么对Mock对象追加Verifiable和Verify方法,并且使用try/catch/finally的缘故。
[TestMethod()] public void ProcessRequestTest_Authorized_Request() { Mock<HttpContextBase> mockContext = new Mock
<HttpContextBase>(MockBehavior.Strict); mockContext.Setup(c => c.User.Identity.
IsAuthenticated).Returns(true); mockContext.Setup(c => c.User.IsInRole(It.
IsAny<string>())).Returns(false); mockContext.Setup(c => c.User.IsInRole("user")).
Returns(true).Verifiable(); Mock<AuthorizedHandler> mockHandler = new Mock
<AuthorizedHandler> { CallBase = true }; mockHandler.Setup(c => c.ProcessRequestCore
(It.IsAny<HttpContextBase>())) .AtMostOnce().Verifiable(); mockHandler.Setup(c => c.AuthorizedRoles).
Returns(new string[] { "admin", "user" }) .Verifiable(); mockHandler.Object.ProcessRequest(mockContext.Object); mockHandler.Verify(); mockContext.Verify(); }
最后的测试自然是正常流程的测试。在这里我们要检验的是正常情况下ProcessRequestCore是否“被调用,而且只被调用了一次”。如果您能够理解前两个测试,这个测试应该也同样简单才是。
之前都是在测试Http Handler,不过Http Module的测试也较为类似。其原则是相同的:把所有逻辑转发给针对抽象的方法。我们这次就以最最经典的URL重写功能为例,如下:
public interface IUrlRewriteSource { string GetRewritePath(string rawUrl); } public class UrlRewriteModule : IHttpModule { public void Dispose() { } public UrlRewriteModule() : this(new RegexUrlRewriteSource(...)) { } internal UrlRewriteModule(IUrlRewriteSource source) { this.m_source = source; } private IUrlRewriteSource m_source; public void Init(HttpApplication httpApp) { httpApp.BeginRequest += (sender, e) => { HttpContext context =
((HttpApplication)sender).Context; this.TryRewritePath(new HttpContextWrapper(context)); }; } internal void TryRewritePath
(HttpContextBase context) { string newUrl = this.m_source.GetRewritePath
(context.Request.RawUrl); if (!String.IsNullOrEmpty(newUrl)) { context.RewritePath(newUrl); } } }
由于测试需要,我们提取出一个IUrlRewriteSource接口。ASP.NET本身会通过无参数的构造函数进行创建,这时就会使用默认的RegexUrlRewriteSource对象。而在测试的时候,就要创建Mock对象并通过构造函数的重载进行“依赖注入”了。在Init方法中我们直接使用匿名委托来作为BeginRequest事件的处理函数,而其中就把逻辑直接委托给TryRewritePath方法了。TryRewritePath方法会判断Source中得知是否需要进行URL重写,并且在需要的时候调用RewritePath方法。它的测试如下:
[TestMethod] public void TryRewritePathTest_No_Rewrite() { Mock<IUrlRewriteSource> mockSource =
new Mock<IUrlRewriteSource>(); mockSource.Setup(s => s.GetRewritePath(It.IsAny<string>())) .Returns<string>(null).Verifiable(); Mock<HttpContextBase> mockContext =
new Mock<HttpContextBase>(MockBehavior.Strict); mockContext.Setup(c => c.Request.RawUrl).Returns("Hello"); mockContext.Setup(c => c.RewritePath(It.IsAny<string>())) .Throws(new InvalidOperationException
("Should not call the RewritePath method.")); UrlRewriteModule module = new UrlRewriteModule
(mockSource.Object); module.TryRewritePath(mockContext.Object); mockSource.Verify(); } [TestMethod] public void TryRewritePathTest_Rewrite_Article_Detail_Page() { string rawUrl = "Article/5"; string targetUrl = "~/Article.aspx?id=5"; Mock<IUrlRewriteSource> mockSource =
new Mock<IUrlRewriteSource>(); mockSource.Setup(s => s.GetRewritePath
(It.IsAny<string>())).Throws( new InvalidOperationException
("Why so many unnecessary method calls?")); mockSource.Setup(s => s.GetRewritePath(rawUrl)).
Returns(targetUrl).Verifiable(); Mock<HttpContextBase> mockContext =
new Mock<HttpContextBase>(MockBehavior.Strict); mockContext.Setup(c => c.Request.RawUrl).Returns(rawUrl); mockContext.Setup(c => c.RewritePath(targetUrl)).Verifiable(); UrlRewriteModule module = new UrlRewriteModule(mockSource.Object); module.TryRewritePath(mockContext.Object); mockSource.Verify(); mockContext.Verify(); }
在不需要重写的情况下,IUrlRewriteSource对象的GetRewritePath方法永远返回null,而此时也不应该调用HttpContext的RewritePath方法。否则,便判断给出合适的RawUrl和重写目标,并判断RewritePath方法有没有正确调用过便是。其实单元测试就这么简单。(本文由控件中国网转载)