[Web API]如何針對 Message Handler 進行單元測試

  • 8813
  • 0

[Web API]如何針對 Message Handler 進行單元測試

前言

ASP.NET Web API 與 ASP.NET MVC 一樣,在架構上提供了相當多的擴充點,以及一些可以拿來擴充的基底類別(如 DelegatingHandlerMessageProcessingHandler)。其中 message handler 一直是 Web API 中的一個重要亮點,因為在實務上,要做到關注點分離,讓從 controller 開始都不用管 message 在 communication 上是如何被處理與轉換的,就是得透過 message handler 這條責任鏈搭配 AOP 的方式,才能設計地抽象跟彈性。

顧名思義, message handler 就是用來處理 message 的,它跟商業邏輯無關,主要針對的對象就是兩個: HttpRequestMessageHttpResponseMessage

然而牽扯到 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 主要負責的事情只有:

  1. 由 IAuthenticationService 來決定 Authentication 是否 Valid 。至於如何取得 Principal 則交由 IAuthenticationService 的 instance 來決定,這邊只負責抽象的流程。若取得到 Principal ,則塞回 Thread.CurrentPrincipal 跟 HttpContext.Current.User ,以便後續的程式都可以直接取用 IPrincipal 的資訊,包含 Identity 與 Roles 。
  2. 如果驗證通過,則將 request message 繼續往後送,也就是呼叫 base.SendAsync() ,這邊的 base 其實就是責任鏈中 abstract class 所扮演的角色。
  3. 如果驗證不通過,則直接回傳一個 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 往後拋。如下圖所示:

image

(資料來源: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);
        }

結論

  1. 責任鏈的作法真的是應用到淋漓盡致啊
  2. ASP.NET Web API 的架構跟擴充性真的設計地很好
  3. 單元測試所擁有的快速、獨立、可重複執行的特性,是無法被其他測試完整取代的 (當然也無法用單元測試來取代其他測試)

 

Reference

  1. ASP.NET Web API HTTP Message Lifecycle - 中文(繁體)
  2. HTTP Message Handlers
  3. Unit Testing Message Handler in Asp.net WebAPI

blog 與課程更新內容,請前往新站位置:http://tdd.best/