ASP.NET MVC單元測試:HttpContext類的Path屬性解惑
有關HttpContext類的Path屬性問題描述
前一段時間有朋友在郵件中向我抱怨,說他們團隊在使用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屬性原理
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相關的問題應該已經不會給您造成太大問題了。
以上就是對HttpContext類的Path屬性的問題解惑。本文來自老趙點滴:《在單元測試時指定HttpContext的各種Path》
【編輯推薦】