當在設計中引入 AOP 的設計時,就會出現幾個用來處理橫切面的攔截器(Interceptor),然而這些攔截器就如同 ASP.NET Web API 中的 message handler 或是 ASP.NET MVC 中的 Action Filter 一般,實際使用的 context 是成為寄托於某個類別或 interface 上的 wrapper。
因為極度地抽象化之後,要獨立測試攔截器變得沒那麼單純,這篇文章將帶著大家避開複雜的 DI container 註冊,也能簡單地對攔截器撰寫單元測試。
這一點非常重要,如果你連攔截器的設計,也想要 TDD 開發的話,你就得先擬出怎麼簡單使用你所設計攔截器物件的情境。
這篇文章不打算介紹太多 AOP 的概念,有興趣的朋友,請自行參考 blog 的 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();
}
}
接著看一下 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
物件。 - 當呼叫
SutForMyInterceptor
的DoSomething()
時,且拋出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);
}
}
一樣驗證通知攔截器的情境,這樣的測試代碼是不是乾淨、好懂許多呢?
總結
單元測試是一切設計的基底,我目前的每一門工程實踐培訓課程,也都會讓學員使用單元測試來驗證自己的設計是否正確。
簡單摘要本篇文章幾個重點:
- 要驗證攔截器,你還是需要一個載體,也就是這邊的
SutForMyInterceptor
,寫得越單純越好。 - 針對載體,如果有 interface,就用
ProxyGenerator
針對 Interface 產生攔截器 Proxy,讓測試情境取得測試目標的動作越單純越好,避免繁複的 DI Container 註冊與取用。如果是不想透過 interface,那 class 要被攔截的行為,就得標記成virtual
,讓ProxyGenerator
改用子類的方式做攔截。 - 測試記得一定要重構,讓測試是用來描述情境,而不是攤開一堆測試代碼,只能單純驗證產品代碼的對錯。
blog 與課程更新內容,請前往新站位置:http://tdd.best/