[Web API]如何針對 Message Handler 進行單元測試
前言
ASP.NET Web API 與 ASP.NET MVC 一樣,在架構上提供了相當多的擴充點,以及一些可以拿來擴充的基底類別(如 DelegatingHandler 或 MessageProcessingHandler)。其中 message handler 一直是 Web API 中的一個重要亮點,因為在實務上,要做到關注點分離,讓從 controller 開始都不用管 message 在 communication 上是如何被處理與轉換的,就是得透過 message handler 這條責任鏈搭配 AOP 的方式,才能設計地抽象跟彈性。
顧名思義, message handler 就是用來處理 message 的,它跟商業邏輯無關,主要針對的對象就是兩個: HttpRequestMessage 與 HttpResponseMessage 。
然而牽扯到 HTTP 的 request 與 response ,以往要進行單元測試是難上加難,因為一旦牽扯到網路,就不是單純的單元測試,而是依賴於硬體與網路的整合測試。
希望這篇文章,讓有興趣針對 message handler 進行單元測試的朋友,能簡單上手。
範例 – AuthenticationMessageHandler
程式碼如下:
public interface IAuthenticationService
{
IPrincipal GetPrincipal(HttpRequestMessage request);
}
public class AuthenticationMessageHandler : DelegatingHandler
{
private IAuthenticationService _authenticationService;
public AuthenticationMessageHandler(IAuthenticationService authenticationService)
{
this._authenticationService = authenticationService;
}
protected override System.Threading.Tasks.Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
{
if (request == null)
{
throw new ArgumentNullException("request");
}
var isAuthenticatedValid = this.CheckAuthentication(request);
if (isAuthenticatedValid)
{
return base.SendAsync(request, cancellationToken);
}
else
{
var taskCompletionSource = new TaskCompletionSource<HttpResponseMessage>();
var response = request.CreateResponse(HttpStatusCode.Unauthorized);
taskCompletionSource.SetResult(response);
var invalidResult = taskCompletionSource.Task;
return invalidResult;
}
}
private bool CheckAuthentication(HttpRequestMessage request)
{
var principal = this._authenticationService.GetPrincipal(request);
if (principal != null)
{
Thread.CurrentPrincipal = principal;
if (HttpContext.Current != null)
{
HttpContext.Current.User = principal;
}
return true;
}
else
{
return false;
}
}
}
AuthenticationMessageHandler 主要負責的事情只有:
- 由 IAuthenticationService 來決定 Authentication 是否 Valid 。至於如何取得 Principal 則交由 IAuthenticationService 的 instance 來決定,這邊只負責抽象的流程。若取得到 Principal ,則塞回 Thread.CurrentPrincipal 跟 HttpContext.Current.User ,以便後續的程式都可以直接取用 IPrincipal 的資訊,包含 Identity 與 Roles 。
- 如果驗證通過,則將 request message 繼續往後送,也就是呼叫 base.SendAsync() ,這邊的 base 其實就是責任鏈中 abstract class 所扮演的角色。
- 如果驗證不通過,則直接回傳一個 response ,其 StatusCode 為 Unauthorized 。
這樣設計的好處是,如同前面文章中介紹如何快速套用 DI framework (請參考:[ASP.NET Web API]3 分鐘搞定 DI framework–Unity Application Block),只需要在 RegisterType 那邊將 IAuthenticationService 註冊完成,就能讓 AuthenticationMessageHandler 維持僅相依於介面,且隨時都可以從 DI container 註冊的部份,來抽換任何相依的實體,以滿足開放封閉原則(OCP )。
如何針對 MessageHandler 進行單元測試
簡單擬出兩個測試案例,分別是可以取得 Principal 代表合法,以及無法取得 Principal 代表非法的情境。測試方法如下:
[TestClass]
public class AuthenticationMessageHandlerTest
{
[TestMethod]
public void 無法取得Principal_Authentication_Invalid_應回傳Unauthorized()
{
}
[TestMethod]
public void 正確取得Principal_Authentication_Valid_應回傳OK()
{
}
}
一樣請大家在測試專案中,從 NuGet 安裝 RhinoMocks 以便進行 stub 的設計。
接著先把 stub 做好,無法取得 Principal 的部份,只需要讓 stub object 回傳 null 即可。可以取得 Principal 的部份,則簡單塞入使用者名稱為 joey, 角色為 admin 與 power user 的 principal ,程式碼如下:
[TestMethod]
public void 無法取得Principal_Authentication_Invalid_應回傳Unauthorized()
{
//arrange
var authStub = MockRepository.GenerateStub<IAuthenticationService>();
authStub.Stub(x => x.GetPrincipal(Arg<HttpRequestMessage>.Is.Anything)).Return(null);
var target = new AuthenticationMessageHandler(authStub);
//todo
}
[TestMethod]
public void 正確取得Principal_Authentication_Valid_應回傳OK()
{
//arrange
var authStub = MockRepository.GenerateStub<IAuthenticationService>();
var identity = new GenericIdentity("joey");
var principal = new GenericPrincipal(identity, new string[] { "admin", "power user" });
authStub.Stub(x => x.GetPrincipal(Arg<HttpRequestMessage>.Is.Anything)).Return(principal);
var target = new AuthenticationMessageHandler(authStub);
//todo
}
接下來需要做的,就是最關鍵的地方,我們需要做一個假的 MessageHandler 來放到測試目標,也就是 AuthenticationMessageHandler 後面,別忘了,這是一條責任鏈。呼叫 base.SendAsync() 時,就是把 request message 往後拋。如下圖所示:
(資料來源:http://www.asp.net/web-api/overview/working-with-http/http-message-handlers)
因此,這邊建立一個假的 message handler ,且讓回傳的 HttpResponseMessage 可以由外面來決定,FakeInnerHandler 如下所示:
internal class FakeInnerHandler : DelegatingHandler
{
internal HttpResponseMessage Message { get; set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (Message == null)
{
return base.SendAsync(request, cancellationToken);
}
return Task.Factory.StartNew(() => Message);
}
}
要用 delegate 來讓外面決定 SendAsync 要回傳的 HttpResponseMessage 也可以,不過 property 看起來單純多了,比較不嚇人。
而取名為 FakeInnerHandler 是因為責任鏈要接起來,是透過 InnerHandler 的 property 來串接。如何將 FakeInnerHandler 接在 AuthenticationMessageHandler 之後呢?程式碼如下:
[TestMethod]
public void 無法取得Principal_Authentication_Invalid_應回傳Unauthorized()
{
//arrange
var authStub = MockRepository.GenerateStub<IAuthenticationService>();
authStub.Stub(x => x.GetPrincipal(Arg<HttpRequestMessage>.Is.Anything)).Return(null);
var target = new AuthenticationMessageHandler(authStub);
target.InnerHandler = new FakeInnerHandler
{
Message = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("fake response")
}
};
//todo
}
責任鏈的說明,可以參考:[ASP.NET]重構之路系列v10 –責任鏈模式的應用 ,裡面的 NextInterviewer 就跟這裡的 InnerHandler 意思是一樣的,裡面的 GoNext() 就跟 SendAsync() 一樣意思
到這邊,已經模擬完成 IAuthenticationService 會回傳 null, 而 Controller 或下一個 MessageHandler 會回傳 HttpStatusCode 為 OK, 內容為 "fake response" 。
接下來只剩下,如何把 request 從責任鏈的開頭打進去。這邊只需要透過 HttpMessageInvoker 這個 class 裡面的 SendAsync() 即可。程式碼如下:
[TestMethod]
public void 無法取得Principal_Authentication_Invalid_應回傳Unauthorized()
{
//arrange
var authStub = MockRepository.GenerateStub<IAuthenticationService>();
authStub.Stub(x => x.GetPrincipal(Arg<HttpRequestMessage>.Is.Anything)).Return(null);
var target = new AuthenticationMessageHandler(authStub);
target.InnerHandler = new FakeInnerHandler
{
Message = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("fake response")
}
};
var client = new HttpMessageInvoker(target);
var request = new HttpRequestMessage(HttpMethod.Post, "http://mytest/api/fake");
//act
var actual = client.SendAsync(request, new CancellationToken()).Result;
//assert
Assert.AreEqual(System.Net.HttpStatusCode.Unauthorized, actual.StatusCode);
Assert.IsNull(actual.Content);
}
[TestMethod]
public void 正確取得Principal_Authentication_Valid_應回傳OK()
{
//arrange
var authStub = MockRepository.GenerateStub<IAuthenticationService>();
var identity = new GenericIdentity("joey");
var principal = new GenericPrincipal(identity, new string[] { "admin", "power user" });
authStub.Stub(x => x.GetPrincipal(Arg<HttpRequestMessage>.Is.Anything)).Return(principal);
var target = new AuthenticationMessageHandler(authStub);
target.InnerHandler = new FakeInnerHandler
{
Message = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("fake response")
}
};
var client = new HttpMessageInvoker(target);
var request = new HttpRequestMessage(HttpMethod.Post, "http://mytest/api/fake");
//act
var actual = client.SendAsync(request, new CancellationToken()).Result;
//assert
Assert.AreEqual(System.Net.HttpStatusCode.OK, actual.StatusCode);
Assert.AreEqual("fake response", actual.Content.ReadAsStringAsync().Result);
}
結論
- 責任鏈的作法真的是應用到淋漓盡致啊
- ASP.NET Web API 的架構跟擴充性真的設計地很好
- 單元測試所擁有的快速、獨立、可重複執行的特性,是無法被其他測試完整取代的 (當然也無法用單元測試來取代其他測試)
Reference
- ASP.NET Web API HTTP Message Lifecycle - 中文(繁體)
- HTTP Message Handlers
- Unit Testing Message Handler in Asp.net WebAPI
blog 與課程更新內容,請前往新站位置:http://tdd.best/