Hello World
Spiga

在单元测试时指定HttpContext的各种Path

2009-08-21 10:02 by 老赵, 6406 visits

前一段时间有朋友在邮件中向我抱怨,说他们团队在使用ASP.NET MVC开发时,在单元测试的时候总是遇到一些不那么方便的地方。例如,对于HttpContext中各种千奇百怪的Path总是无法掌控。例如某个功能会用到HttpContext的Path属性,有的又要用到RawUrl——有的又会涉及到HostName。于是在单元测试的时候,就可能需要填充Mock对象的多种Path属性,而这几种Path属性的值,在理论上还有关系。这其实还是小事,一个麻烦的事情在于,如果功能实现的方式变了,例如原本使用RawUrl属性,而后来忽然觉得应该使用CurrentExecutionFilePath比较合适,于是单元测试就必须跟着改。如此反复,疲于奔命。

就我个人经验看来,这种情况还是蛮常见的,因为某些时候两种Path属性的值差不多,看上去都可以正常使用,于是刚开始编写的时候可能选择了其中一个。但是后来发现,在另一些情况下两种Path就有区别了,而且应该使用的是另一个属性,于是不得不修改,进而单元测试失败了。于是他问我,有没有什么好方法来“完整而可靠地”设置那些缤繁复杂的Path属性。我之前其实也是根据需求设置各种Path属性,但是这的确不好,最重要的问题在于“单元测试”需要了解太多“被测试方法”的实现细节了,这种依赖非常的不可靠。虽然这也是Mock对象被人诟病的特点之一,但是如果我们能够缓解这个缺陷自然再好不过了。

不过话说回来,在“应对”这个问题之前,您要先了解目前的功能是不是真要访问HttpContext中的各种Path。ASP.NET MVC为了提高程序的可测试性作了很多努力,或者说,将“关注点”进行了很大程度的分离。在大部分情况下,我们都能够不去触及HttpContext,而且我们应该尽可能避免这种情况的发生。例如,对Controller做单元测试的时候直接传递参数,为Model Binder做单元测试的时候使用ValueProvider。想来想去,会直接使用到HttpContext的Path属性的场景不多,可能自定义Route算是一个吧,因为它的功能就是解析URL。

HttpContext的Path属性都是通过HttpRequest对象获得的。而事实上ASP.NET中的HttpRequest对象已经为我们提供一种直接通过URL构造的功能:

var request = new HttpRequest(
    "",                                      /* filename */
    "http://www.cnblogs.com/JeffreyZhao/",   /* url */
    "hello=world");                          /* querystring */

估计ASP.NET开发团队也知道URL是个难办的问题,为我们预留了这样一个构造函数。这时的request对象会预填了大多数Path相关的属性:

request
{System.Web.HttpRequest}
    AcceptTypes: null
    AnonymousID: null
    ApplicationPath: null
    AppRelativeCurrentExecutionFilePath: threw an exception of type 'System.NullReferenceException'
    Browser: null
    ClientCertificate: threw an exception of type 'System.NullReferenceException'
    ContentEncoding: threw an exception of type 'System.NullReferenceException'
    ContentLength: 0
    ContentType: ""
    Cookies: {System.Web.HttpCookieCollection}
    CurrentExecutionFilePath: "/JeffreyZhao/"
    FilePath: "/JeffreyZhao/"
    Files: {System.Web.HttpFileCollection}
    Filter: {System.Web.HttpInputStreamFilterSource}
    Form: {}
    Headers: {}
    HttpMethod: "GET"
    InputStream: {System.Web.HttpInputStream}
    IsAuthenticated: threw an exception of type 'System.NullReferenceException'
    IsLocal: false
    IsSecureConnection: false
    LogonUserIdentity: null
    Params: {hello=world}
    Path: "/JeffreyZhao/"
    PathInfo: ""
    PhysicalApplicationPath: threw an exception of type 'System.ArgumentNullException'
    PhysicalPath: ""
    QueryString: {hello=world}
    RawUrl: "/JeffreyZhao/?hello=world"
    RequestType: "GET"
    ServerVariables: {}
    TotalBytes: 0
    Url: {http://www.cnblogs.com/JeffreyZhao/}
    UrlReferrer: null
    UserAgent: null
    UserHostAddress: null
    UserHostName: null
    UserLanguages: null

以上内容是从Visual Studio的Immediate Window中看到的,由此可以发现,其中大部分的Path属性已经准备好了,但是AppRelativeCurrentExecutionFilePath属性抛出异常(还有两个与本地磁盘路径有关的Path就忽略了),因为它需要特定的虚拟路径环境才能计算出来。通过.NET Reflector观察这个属性的实现,会发现其中牵涉到的内容不是一点两点,几乎不可能通过设置外部环境的方式来使其通过。因此,我们最终还是要通过Mock框架来进行设置——反正我们也需要设置HttpRequest的其它属性,不是吗?

var realRequest = new HttpRequest(
    "",                                      /* filename */
    "http://www.cnblogs.com/JeffreyZhao/",   /* url */
    "hello=world");                          /* querystring */
var mockRequest = new Mock<HttpRequestWrapper>(realRequest) { CallBase = true };
mockRequest
    .Setup(r => r.AppRelativeCurrentExecutionFilePath)
    .Returns("~" + realRequest.CurrentExecutionFilePath);

这里还是使用Moq框架,而Mock的对象则是HttpRequestWrapper类型,而不是我们常用的HttpRequestBase类型。HttpRequestWrapper的特点便是可以“塞入”一个真正的HttpRequest对象,然后把所有成员都委托给这个HttpRequest对象。我们在构建一个Mock<HttpRequestWrapper>对象之后,还需要把CallBase属性设为true,这样便可以让Mock对象在默认情况下直接使用Wrapper的实现了。

有了Request,我们便可以构建一个HttpContext的Mock对象:

var mockContext = new Mock<HttpContextBase>();
mockContext.Setup(c => c.Request).Returns(mockRequest.Object);

但是,Moq框架有个限制,那就是如果您指定了这里的Request对象,再去通过HttpContext指定Request中的其他属性,就会把原来的HttpRequest对象给覆盖。也就是说,下面的代码会让我们对HttpRequest做的努力付之东流:

mockContext.Setup(c => c.Request.Form).Returns(new NameValueCollection());

这样您会发现,mockContext.Object.Request下除了Form外的其他属性都没有值了(或抛出异常,视您Mock时的Behavior是Loose还是Strict而定)。因此,如果我们希望进一步修改HttpRequest中属性的时候,只能直接使用那个Mock<HttpRequestWrapper>对象进行设置。我不清楚其他Mock框架的行为如何,如果您使用的也是Moq框架,可能就只得这么做了。

为了使用方便,我也在测试项目中准备了这样一个辅助方法:

public static class MockHelper
{
    public static Mock<HttpContextBase> MockRequest(string url, out Mock<HttpRequestWrapper> mockRequest)
    {
        int index = url.IndexOf('?');
        string path = index >= 0 ? url.Substring(0, index) : url;
        string queryString = index >= 0 ? url.Substring(index + 1) : "";

        var realRequest = new HttpRequest("", path, queryString);
        mockRequest = new Mock<HttpRequestWrapper>(realRequest) { CallBase = true };
        mockRequest
            .Setup(r => r.AppRelativeCurrentExecutionFilePath)
            .Returns("~" + realRequest.CurrentExecutionFilePath);

        var mockContext = new Mock<HttpContextBase>();
        mockContext.Setup(c => c.Request).Returns(mockRequest.Object);
        return mockContext;
    }
}

于是我们就可以更方便地进行相关的单元测试。例如,我们“象征性”地测试一下ASP.NET Routing中内置的Route类型:

[Fact]
public void URL_Capturing_and_Generation()
{
    // prepare route
    Route route = new Route("{controller}/{action}/{id}", null);

    // Mock request
    string url = "http://www.cnblogs.com/Home/Index/5";
    Mock<HttpRequestWrapper> mockRequest;
    var mockContext = MockHelper.MockRequest(url, out mockRequest);
    mockContext.Setup(c => c.Response.Charset).Returns("utf-8"); // if you need

    // test data capturing
    RouteData routeData = route.GetRouteData(mockContext.Object);
    Assert.Equal("Home", routeData.GetRequiredString("controller"));
    Assert.Equal("Index", routeData.GetRequiredString("action"));
    Assert.Equal("5", routeData.GetRequiredString("id"));

    // test url generation
    var hash = new { controller = "Account", action = "List", id = 1};
    var values = new RouteValueDictionary(hash);
    var requestContext = new RequestContext(mockContext.Object, routeData);
    var pathData = route.GetVirtualPath(requestContext, values);
    Assert.Equal("Account/List/1", pathData.VirtualPath);
}

具体内容就叙述到这里,目前Path相关的问题应该已经不会给您造成太大问题了。

最近因为项目需要,不得不全身心投入ASP.NET MVC相关的东西,每天都会有不少实践和感受。如果您也有某些想法,不妨也一起交流一下。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

26 条回复

  1. Gnie
    *.*.*.*
    链接

    Gnie 2009-08-21 10:07:00

    老赵的模板为什么设计成这种样式呢?
    正文部分好窄啊,右边菜单栏占了两列。

  2. 老赵
    admin
    链接

    老赵 2009-08-21 10:13:00

    @Gnie
    我希望边栏内容丰富。
    这是为宽屏设计的,你一定使用1024宽的屏幕吧。
    1280或1650看起来会很漂亮。

  3. Gnie
    *.*.*.*
    链接

    Gnie 2009-08-21 10:15:00

    @Jeffrey Zhao:
    哦,我说的呢,在公司是笔记本,回家也没用宽屏上过网,
    呵呵!

  4. PAL
    *.*.*.*
    链接

    PAL 2009-08-21 10:51:00

    老赵最近确实是吃大力丸了,高产啊~~~

  5. 老赵
    admin
    链接

    老赵 2009-08-21 11:00:00

    @PAL
    还有很多可以谈得,下午搬家,下周继续。
    慢慢我也会缩短博客篇幅,提出问题,希望可以引起讨论。
    不知道会不会被人叫着撤下首页,嘿嘿。

  6. billlo[未注册用户]
    *.*.*.*
    链接

    billlo[未注册用户] 2009-08-21 11:28:00

    老趙目前這種寫blog的方式有點像Q/A.
    個人認為很好.贊贊成用這種方式

  7. 麒麟.NET
    *.*.*.*
    链接

    麒麟.NET 2009-08-21 11:52:00

    真的准备一天博两次啊?

  8. 老赵
    admin
    链接

    老赵 2009-08-21 12:07:00

    @麒麟.NET
    最近不得不搞asp.net,有的好写了,唉。

  9. 麒麟.NET
    *.*.*.*
    链接

    麒麟.NET 2009-08-21 12:29:00

    @Jeffrey Zhao
    看到twitter上我给你的留言了吗?鸡蛋不能多吃的,尤其是鸡蛋黄,胆固醇含量太高,对身体不好。健身后可多吃蛋清,补充蛋白质,加速肌肉生长,但一定别吃蛋黄啊!一个成年人每天最多吃两个鸡蛋!

  10. 温景良(Jason)
    *.*.*.*
    链接

    温景良(Jason) 2009-08-21 12:34:00

    呵呵,老赵,提个问题啊,这个moq框架有什么好处和作用啊,不是很了解

  11. 老赵
    admin
    链接

    老赵 2009-08-21 12:48:00

    @温景良(Jason)
    试试看单元测试,再看看Moq看帮助就知道了,就是构造Mock对象的框架。

  12. 有容乃大
    *.*.*.*
    链接

    有容乃大 2009-08-21 13:19:00

    问一下老赵同志:
    在asp.net的后台线程中获取HttpContext.Current.Server对象为空(用于获取虚拟路径),我的做法是在Global的Application_Start中传递一个HttpContext参数,除此之外有没有更好的解决方法?

  13. 老赵
    admin
    链接

    老赵 2009-08-21 13:59:00

    @有容乃大
    HttpContext.Current为空还是HttpContext.Current.Server为空?

  14. 有容乃大
    *.*.*.*
    链接

    有容乃大 2009-08-21 14:08:00

    @Jeffrey Zhao
    Server为空,你可以开一个后台线程或异步方法试一下看。

  15. 老赵
    admin
    链接

    老赵 2009-08-21 14:11:00

    @有容乃大
    我以前用过,结果是HttpContext.Current为空而不是Server为空,我觉得你是不是可以确认一下。
    所以我很不信任HttpContext.Current,不建议使用,如果要使用HttpContext,那么就把它保存起来,比如利用闭包什么的。

  16. 有容乃大
    *.*.*.*
    链接

    有容乃大 2009-08-21 14:17:00

    @Jeffrey Zhao
    确实是HttpContext.Current为空,因为此时不存在上下文特定的http请求,看来这事挺烦。

  17. yybhy[未注册用户]
    *.*.*.*
    链接

    yybhy[未注册用户] 2009-08-21 14:21:00

    不错

  18. 老赵
    admin
    链接

    老赵 2009-08-21 14:21:00

    @有容乃大
    因为HttpContext是基于CallContext的,因此和一个调用有关,一次异步回调自然就不能用HttpContext.Current。
    烦到不烦,我也习惯了,而且自己保存Context是个好习惯啊,否则依赖一个静态属性,怎么做单元测试呢?

  19. 有容乃大
    *.*.*.*
    链接

    有容乃大 2009-08-21 14:26:00

    @Jeffrey Zhao
    多谢,老赵很热心回复的真快。

  20. 码农SeraphWU
    *.*.*.*
    链接

    码农SeraphWU 2009-08-21 14:47:00

    Jeffrey Zhao:
    @Gnie
    我希望边栏内容丰富。
    这是为宽屏设计的,你一定使用1024宽的屏幕吧。
    1280或1650看起来会很漂亮。


    确实,宽屏看起来很舒服

  21. 王德水
    *.*.*.*
    链接

    王德水 2009-08-21 15:12:00

    涉及到Session, Cookie... 等HttpContext的部分确实不容易测试

    希望能有多一点这些测试方面的实践分享,我们正在实行敏捷, 单元测试这一块非常重要

  22. 麒麟.NET
    *.*.*.*
    链接

    麒麟.NET 2009-08-21 16:03:00

    http://space.cnblogs.com/group/topic/31487/
    老赵,你推荐过这本书吗?

  23. 老赵
    admin
    链接

    老赵 2009-08-21 16:15:00

    @麒麟.NET
    艹,就当时有人msn上问我让我评价一下asp.net mvc而已,我都已经忘了这件事情了。
    我没推荐过。

  24. 麒麟.NET
    *.*.*.*
    链接

    麒麟.NET 2009-08-21 16:26:00

    而且使用的还是ASP.NET 3.5 MVC这样不伦不类的名称……

  25. 王德水
    *.*.*.*
    链接

    王德水 2009-08-22 14:40:00

    请教个问题
    asp.net mvc
    测试时我需要读app_data下的xml文件

    在本机我我可以 Load("D:\WEB\APP_DATA\test.xml")

    但提交上去后别人的源码并非一定放入同意位置,

    怎么样测试 "~\APP_DATA\test.xml"这样的代码?

  26. 老赵
    admin
    链接

    老赵 2009-08-23 13:25:00

    @王德水
    具体视情况而定,例如加一个FileLocator都是可以的。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我