【單元測試】如何測試 AOP 中的 interceptor

當在設計中引入 AOP 的設計時,就會出現幾個用來處理橫切面的攔截器(Interceptor),然而這些攔截器就如同 ASP.NET Web API 中的 message handler 或是 ASP.NET MVC 中的 Action Filter 一般,實際使用的 context 是成為寄托於某個類別或 interface 上的 wrapper。

因為極度地抽象化之後,要獨立測試攔截器變得沒那麼單純,這篇文章將帶著大家避開複雜的 DI container 註冊,也能簡單地對攔截器撰寫單元測試。

這一點非常重要,如果你連攔截器的設計,也想要 TDD 開發的話,你就得先擬出怎麼簡單使用你所設計攔截器物件的情境。

這篇文章不打算介紹太多 AOP 的概念,有興趣的朋友,請自行參考 blog 的 AOP 標籤文章

預計在 2019 年上半年,四月~六月之間會開一門 DI/AOP 的培訓課,從解決問題本身出發,介紹使用的 design pattern 概念,從無框架到使用框架,到各種實務應用場景的實戰演練,如您對這門培訓有興趣,請填寫這份 google 表單:我想收到 DI/AOP開課通知,我們將在開課時第一時間送開課通知給您,讓您享有搶早鳥票優惠的權利。

產品代碼

使用場景端,SutForMyInterceptor 類別,透過 Interceptor 的 Attribute 標記,搭配 method 上的 Notify Attribute 標記來指定當發生拋出 MyBusinessException 時,該通知哪個角色。

    [Intercept(typeof(MyInterceptor))]
    public class SutForMyInterceptor : ISomeInterface
    {
        [Notify(Role = Role.OP)]
        public void DoSomething()
        {
            throw new MyBusinessException();
        }
    }
當然,AOP 的設計可以更靈活一點,例如透過多個 Notify Attribute,可以標記「當引發哪一個 exception 時,就通知哪一個特定的 Role」,然後多重標記。

接著看一下 NotifyAttribute 的寫法,其實很單純,因為這只是單純標記給 Interceptor 識別,並透過 reflection 可取得對應標記的資訊。

    public class NotifyAttribute : Attribute
    {
        public Role Role { get; set; }
    }

接下來的重頭戲,當然就是我們的主題:MyInterceptor 攔截器的內容,這個攔截器的作用,就是把原本應該寫在場景端的 try/catch block,針對 catch MyBusinessException 的 error handling,通知對應 Role 的處理,拉到橫切面用攔截器來做。

    public class MyInterceptor : IInterceptor
    {
        private INotification _notification;

        public MyInterceptor(INotification notification)
        {
            _notification = notification;
        }

        public void Intercept(IInvocation invocation)
        {
            try
            {
                invocation.Proceed();
            }
            catch (MyBusinessException ex)
            {
                var notifyAttribute = GetNotifyAttribute(invocation);
                if (notifyAttribute != null)
                {
                    _notification.Notify(notifyAttribute.Role, ex);
                }

                throw;
            }
        }

        private NotifyAttribute GetNotifyAttribute(IInvocation invocation)
        {
            return Attribute.GetCustomAttribute(
                invocation.MethodInvocationTarget,
                typeof(NotifyAttribute)
            ) as NotifyAttribute;
        }
    }

單元測試代碼

先來看個醜陋版的,也是大家容易照產品代碼透過 DI container 註冊使用所寫成的樣子。(更多人應該就不對攔截器寫測試了…)

InterceptorTestByWrapper 測試類別,是透過 DI container 註冊與取用 SUT 來測試攔截器是否符合預期般運作。

    [TestClass]
    public class InterceptorTestByWrapper
    {
        private ContainerBuilder _containerBuilder = ContainerConfig.ContainerBuilder;

        private INotification _notification = Substitute.For<INotification>();

        [TestInitialize]
        public void TestInit()
        {
            ContainerRegister();
        }

        [TestMethod]
        public void DoSomething_throw_MyBusinessException()
        {
            GivenContainerRegister((cb) =>
            {
                cb.RegisterInstance(_notification).As<INotification>();
            });

            WhenDoSomethingWithMyBusinessException();

            _notification.ReceivedWithAnyArgs().Notify(Arg.Is<Role>(r => r == Role.OP), Arg.Any<MyBusinessException>());
        }

        private void WhenDoSomethingWithMyBusinessException()
        {
            var sut = GetSutForMyInterceptor();
            Action action = () => sut.DoSomething();
            action.Should().Throw<MyBusinessException>();
        }

        private void GivenContainerRegister(Action<ContainerBuilder> registerAction)
        {
            registerAction.Invoke(_containerBuilder);
        }

        private void ContainerRegister()
        {
            _containerBuilder.RegisterType<SutForMyInterceptor>()
                .As<ISomeInterface>()
                .EnableInterfaceInterceptors();

            _containerBuilder.RegisterType<MyInterceptor>().SingleInstance();
        }

        private ISomeInterface GetSutForMyInterceptor()
        {
            return ContainerConfig.Container.Resolve<ISomeInterface>();
        }
    }

摘要一下上面測試代碼的重點:

  • 每一次測試執行前,都要註冊 SutForMyInterceptor 以及 ISomeInterface 並啟用 interface 上的攔截器。
  • 驗證通知攔截器的情境中,需要先在 DI container 註冊當碰到 INotification 介面,則取用我們在測試中產生的 mock 物件。
  • 實際要驗證的動作,則是透過 Container 取得 ISomeInterface 對應的物件,也就是帶著 MyInterceptor 攔截器的 SutForMyInterceptor 物件。
  • 當呼叫 SutForMyInterceptorDoSomething() 時,且拋出 MyBusinessException 時,會被 MyInterceptor 中的 try/catch block 攔下,並呼叫我們注入的 INotification mock 物件進行通知。

以上,雖然已經對模擬實務上怎麼使用 DI 與 AOP 的情境最簡化,且測試有經過一定的重構,但其實對意圖的呈現,仍然不是很直覺。


有什麼更簡單的方式,能直接測試 Interceptor 呢?有的,使用 ProxyGenerator,就可以略過統一在 container 註冊,再從 contaienr 取用物件的過程。

InterceptorUnitTest 測試類別,在取得 SutForMyInterceptor 時,透過 ProxyGenerator 產生一層攔截器 Proxy,這樣在調用 SutForMyInterceptor 時就會被攔截器攔截。

    [TestClass]
    public class InterceptorUnitTest
    {
        private INotification _notification = Substitute.For<INotification>();

        [TestMethod]
        public void Intercept_DoSomething()
        {
            WhenDoSomethingBeIntercepted();

            ShouldNotify(Role.OP);
        }

        private void ShouldNotify(Role role)
        {
            _notification.Received(1).Notify(Arg.Is<Role>(r => r == role), Arg.Any<MyBusinessException>());
        }

        private void WhenDoSomethingBeIntercepted()
        {
            Action action = () =>
            {
                var sutForMyInterceptor = CreateInterceptorWrapper(new MyInterceptor(_notification));
                sutForMyInterceptor.DoSomething();
            };
            action.Should().Throw<MyBusinessException>();
        }

        private ISomeInterface CreateInterceptorWrapper(MyInterceptor interceptor)
        {
            return new ProxyGenerator().CreateInterfaceProxyWithTarget<ISomeInterface>(new SutForMyInterceptor(), interceptor);
        }
    }

一樣驗證通知攔截器的情境,這樣的測試代碼是不是乾淨、好懂許多呢?

總結

單元測試是一切設計的基底,我目前的每一門工程實踐培訓課程,也都會讓學員使用單元測試來驗證自己的設計是否正確。

簡單摘要本篇文章幾個重點:

  1. 要驗證攔截器,你還是需要一個載體,也就是這邊的 SutForMyInterceptor,寫得越單純越好。
  2. 針對載體,如果有 interface,就用 ProxyGenerator 針對 Interface 產生攔截器 Proxy,讓測試情境取得測試目標的動作越單純越好,避免繁複的 DI Container 註冊與取用。如果是不想透過 interface,那 class 要被攔截的行為,就得標記成 virtual,讓 ProxyGenerator 改用子類的方式做攔截。
  3. 測試記得一定要重構,讓測試是用來描述情境,而不是攤開一堆測試代碼,只能單純驗證產品代碼的對錯。
如果你對單元測試還有點懵懵懂懂,想深入淺出地學習單元測試,請參考:單元測試實戰操練營 201901 第五梯次

或許您會對下列培訓課程感興趣:

  1. 2021/1/9:【針對遺留代碼加入單元測試的藝術】202101 - 台北
  2. 2021/1/10:【極速開發+】 202101 台北
  3. 2021/2/20~2021/2/21:【演化式設計】測試驅動開發與持續重構 202102

想收到第一手公開培訓課程資訊,或想詢問企業內訓、顧問、教練、諮詢服務的,請洽 Facebook 粉絲專頁:91敏捷開發之路