代码改变世界

使用ASP.NET Abstractions增强ASP.NET应用程序的可测试性

2009-04-23 01:51  Jeffrey Zhao  阅读(22850)  评论(11编辑  收藏  举报

概述

在阅读本文之前,兄弟们请先注意两点:

  • 我们现在谈的是传统ASP.NET应用程序的可测试性,而不是ASP.NET MVC应用程序的可测试性。
  • 我们现在谈的是“增强”,而不是说传统ASP.NET应用程序做不到良好的可测试性,一切皆在人为。

关于可测试性的重要性,老赵觉得已经不需要再过多强调了。如果您想要获得高生产力,为代码编写单元测试似乎已经是必经之路了。不过可惜的是,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进行测试

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的情况?做不到——统统做不到,真啥都别想做。因此我们还是无法使用这种方式进行测试,这第一个例子仅仅是为了内容“完整性”而加上的。

AuthorizedHandler

这个例子就复杂些了,并且直接来源于老赵以前的某个项目的代码——当然现在为了示例进行了简化和改造。在项目中我们往往要编写一些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是否“被调用,而且只被调用了一次”。如果您能够理解前两个测试,这个测试应该也同样简单才是。

UrlRewriteModule

之前都是在测试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方法有没有正确调用过便是。其实单元测试就这么简单。

结束

没啥想说的,就这么结束吧。

您有什么想法吗?说说看吧。