C# Test Legacy Code(2)Static Setter Injection
C# Test Legacy Code(1)Isolated by Inheritance and Override << 前情 在上一篇文章中介紹了「如何使用基本的繼承與覆寫的技巧,進行 isolated unit test 的設計」,其最大的好處是,無須透過 mock framework 與無謂的中介層,且不會影響到原本物件對外公開的行為。就針對 legacy code 加入自動測試的需求來說,這是一種成本低、風險低、效益很高的方式。 在實務開發中,常使用簡單工廠(Simple Factory)以及策略模式(Strategy Pattern)來封裝實作細節,使得 context 流程抽象穩定,並達到開放封閉原則(Open/Close Principle, OCP)中所蘊含的可抽換實作的彈性。在 context 流程中,透過簡單工廠依據條件來取得 interface 的 instance 固然美好,卻往往因為與簡單工廠的 static function 直接耦合,導致這段 context 流程無法進行 isolated unit test。 這篇文章要說明的小技巧,能讓 developer 不需要為此廢棄簡單工廠不用,而大費周章改用抽象工廠(Abstract Factory Pattern)。
Example舉例來說,有個出貨 service 可針對訂單的便利商店類別,來進行不同的出貨方式。目前支援的便利商店只有 Seven 與 Family 兩種。程式碼(Github Commit Link)如下: public enum StoreType { /// <summary> /// 7-11 /// </summary> Seven = 0, /// <summary> /// 全家 /// </summary> Family = 1 } public class ShipService { public void ShippingByStore(List<Order> orders) { // handle seven's orders var ordersBySeven = orders.Where(x => x.StoreType != StoreType.Family); var sevenService = new SevenService(); foreach (var order in ordersBySeven) { sevenService.Ship(order); } // handle family's orders var ordersByFamily = orders.Where(x => x.StoreType == StoreType.Family); var familyService = new FamilyService(); foreach (var order in ordersByFamily) { familyService.Ship(order); } } } public class Order { public StoreType StoreType { get; set; } public int Id { get; set; } public int Amount { get; set; } } public class SevenService { internal void Ship(Order order) { // seven web service var client = new HttpClient(); client.PostAsync("http://api.seven.com/Order", order, new JsonMediaTypeFormatter()); } } public class FamilyService { internal void Ship(Order order) { // family web service var client = new HttpClient(); client.PostAsync("http://api.family.com/Order", order, new JsonMediaTypeFormatter()); } } [TestMethod] public void TestShippingByStore_Seven_1_Order_Family_2_Orders() { //arrange var target = new ShipService(); var orders = new List<Order> { new Order{ StoreType= StoreType.Seven, Id=1}, new Order{ StoreType= StoreType.Family, Id=2}, new Order{ StoreType= StoreType.Family, Id=3}, }; //act target.ShippingByStore(orders); //todo, assert //ShipService should invoke SevenService once and FamilyService twice } 程式碼說明:將訂單分成 Seven 與 Family 的訂單集合,交給對應的 service 進行出貨。 上述的作法已經將兩個便利商店出貨的職責,分屬在 SevenService 與 FamilyService 兩個類別上。但抽象來看,其實就是將訂單交由便利商店進行出貨。因此下一步便是抽象出便利商店的介面,來供 SevenService 與 FamilyService 實作,並讓 context (也就是這個例子中的 ShipService.ShippingByStore方法)只需要相依於便利商片介面,而無須與實作的子類耦合。 Refactor → Extract Interface定義一個便利商店介面 IStoreService 擁有 Ship(orders) 方法,並且讓 SevenService 與 FamilyService 實作此介面。程式碼(版本差異)如下: public class SevenService : IStoreService { public void Ship(Order order) { // seven web service var client = new HttpClient(); client.PostAsync("http://api.seven.com/Order", order, new JsonMediaTypeFormatter()); } } public class FamilyService : IStoreService { public void Ship(Order order) { // family web service var client = new HttpClient(); client.PostAsync("http://api.family.com/Order", order, new JsonMediaTypeFormatter()); } } public interface IStoreService { void Ship(Order order); } Implement Strategy PatternContext 端則透過一個 private function 來依據訂單的便利商店類型,取得 IStoreService 對應的 instance,簡化 context 端的邏輯,把兩個迴圈的處理抽象唯一個迴圈,並只抽象為相依於 IStoreService 。程式碼(版本差異)如下: public class ShipService { private FamilyService _family = new FamilyService(); private SevenService _seven = new SevenService(); public void ShippingByStore(List<Order> orders) { //// handle seven's orders //var ordersBySeven = orders.Where(x => x.StoreType != StoreType.Family); //var sevenService = new SevenService(); //foreach (var order in ordersBySeven) //{ // sevenService.Ship(order); //} //// handle family's orders //var ordersByFamily = orders.Where(x => x.StoreType == StoreType.Family); //var familyService = new FamilyService(); //foreach (var order in ordersByFamily) //{ // familyService.Ship(order); //} foreach (var order in orders) { // strategy pattern implementation IStoreService storeService = GetStoreService(order); storeService.Ship(order); } } private IStoreService GetStoreService(Order order) { if (order.StoreType == StoreType.Family) { return this._family; } else { return this._seven; } } } 程式碼說明:針對每一筆訂單屬於哪一家便利商店,使用對應的便利商店實體來進行出貨。 下一步,就是將生成物件的職責,從 context 物件抽離出去。 Implement Simple Factory Pattern建立一個 SimpleFactory 類別,將剛剛 ShipService 中取得 IStoreService instance 的 private function 抽到此類別中。程式碼(版本差異)如下: public class ShipService { //private FamilyService _family = new FamilyService(); //private SevenService _seven = new SevenService(); public void ShippingByStore(List<Order> orders) { foreach (var order in orders) { // simple factory pattern implementation IStoreService storeService = SimpleFactory.GetStoreService(order); storeService.Ship(order); } } //private IStoreService GetStoreService(Order order) //{ // if (order.StoreType == StoreType.Family) // { // return this._family; // } // else // { // return this._seven; // } //} } public class SimpleFactory { private static IStoreService sevenService = new SevenService(); private static IStoreService familyService = new FamilyService(); public static IStoreService GetStoreService(Order order) { if (order.StoreType == StoreType.Family) { return sevenService; } else { return familyService; } } }
程式碼說明:在套用了策略模式與簡單工廠後,出貨 service 的 context 流程與職責更加清楚了,針對傳入的每張訂單類型,從工廠取得對應的便利商店服務,進行出貨。 在一切看似美好的時候,請回過頭看一開始的測試程式,需求並不會因為重構得多乾淨清楚而有所改變。當傳入三張訂單,其中一張為 Seven 兩張為 Family 時,仍然希望呼叫 SevenService 出貨一次,呼叫 FamilyService 出貨兩次。 [TestMethod] public void TestShippingByStore_Seven_1_Order_Family_2_Orders() { //arrange var target = new ShipService(); var orders = new List<Order> { new Order{ StoreType= StoreType.Seven, Id=1}, new Order{ StoreType= StoreType.Family, Id=2}, new Order{ StoreType= StoreType.Family, Id=3}, }; //act target.ShippingByStore(orders); //todo, assert //ShipService should invoke SevenService once and FamilyService twice } 但重構完成的程式碼,仍然需要透過 HttpClient 與 Seven 和 Family 的 Web Service 介接,倘若便利商店的 Web Service 仍未開發完成,或是在測試環境無法使用 Web Service 的話,那這段出貨的邏輯仍然無法被驗證。 Internal Static Setter For Unit Test Injection其實只需要一個簡單的小技巧,針對簡單工廠 get instance 的 static function ,設計一個 for 測試程式注入的 static setter 即可。在測試程式 act 步驟之前,透過 static setter 注入 stub/mock object 到簡單工廠裡面,自然在測試 act 的方法時,就會與 stub/mock object 進行互動。程式碼(版本差異)如下: public class SimpleFactory { //private static IStoreService sevenService = new SevenService(); private static IStoreService sevenService; //private static IStoreService familyService = new FamilyService(); private static IStoreService familyService; //add a internal SevenService setter for test project to inject stub/mock object internal static void SetSevenService(IStoreService stub) { sevenService = stub; } //add a internal FamilyService setter for test project to inject stub/mock object internal static void SetFamilyService(IStoreService stub) { familyService = stub; } public static IStoreService GetStoreService(Order order) { if (order.StoreType == StoreType.Family) { return sevenService ?? new SevenService(); } else { return familyService ?? new FamilyService(); } } } 程式碼說明:當 context 呼叫過 SimpleFactory 的 internal setter function ,則 GetStoreService() 將回傳 setter 所注入的 instance 。 接著先在 SevenService 與 FamilyService 的出貨方法中,加入檢查 web service 回傳的 HttpStatusCode 是否為 200 OK。也就是 response.Result.EnsureSuccessStatusCode(); 這一行。 public class FamilyService : IStoreService { public void Ship(Order order) { // family web service var client = new HttpClient(); var response = client.PostAsync("http://api.family.com/Order", order, new JsonMediaTypeFormatter()); response.Result.EnsureSuccessStatusCode(); } } public class SevenService : IStoreService { public void Ship(Order order) { // seven web service var client = new HttpClient(); var response = client.PostAsync("http://api.seven.com/Order", order, new JsonMediaTypeFormatter()); response.Result.EnsureSuccessStatusCode(); } } 在測試程式中使用簡單工廠 Setter 注入 Mock 物件在測試程式中使用簡單工廠的 internal setter 注入 mock 物件之前,以 C# 來說,要先將 internal 的宣告公開給測試專案使用。因此先將 [InternalsVisibleTo] 加入到 AssemblyInfo.cs 中,如下所示: [assembly: InternalsVisibleTo("SimpleFactoryLegacy.Test")] 接著在測試程式中,使用 NSubstitute 來建立 mock object 並透過 static setter 注入到 SimpleFactory 中。程式碼(版本差異)如下: [TestMethod] public void TestShippingByStore_Seven_1_Order_Family_2_Orders() { //arrange var target = new ShipService(); var orders = new List<Order> { new Order{ StoreType= StoreType.Seven, Id=1}, new Order{ StoreType= StoreType.Family, Id=2}, new Order{ StoreType= StoreType.Family, Id=3}, }; //set stub by simple factory's internal setter var stubSeven = Substitute.For<IStoreService>(); SimpleFactory.SetSevenService(stubSeven); var stubFamily = Substitute.For<IStoreService>(); SimpleFactory.SetFamilyService(stubFamily); //act target.ShippingByStore(orders); //assert //ShipService should invoke SevenService once and FamilyService twice stubSeven.Received(1).Ship(Arg.Is<Order>(x => x.StoreType == StoreType.Seven)); stubFamily.Received(2).Ship(Arg.Is<Order>(x => x.StoreType == StoreType.Family)); } 程式碼說明:如原本需求所說,期望 SevenService.Ship() 被呼叫一次,且傳入的訂單 StoreType 應為 Seven 。並期望 FamilyService.Ship() 被呼叫兩次,且傳入的訂單 StoreType 應為 Family 。 這時執行測試程式會發現,執行結果並不如預期。測試是紅燈,原因是在重構過程抽出 SimpleFactory 時,一個手誤把 == 與 != 搞混了,導致 Seven 的訂單,是使用 FamilyService 。而 Family 訂單則使用 SevenService 。測試結果如下圖所示: Fix Defect And Pass Test最後只需要把手誤的低級錯誤 == 改成 != ,就可以通過測試了。程式碼(版本差異)如下: public static IStoreService GetStoreService(Order order) { // 把 == 改成 != if (order.StoreType != StoreType.Family) { return sevenService ?? new SevenService(); } else { return familyService ?? new FamilyService(); } } 結論不要為了可測試性,導致原本 production code 剛好滿足需求的設計去增加無謂的設計,反而弄得不好維護。 這篇文章雖然增加了 internal 的 static setter 會導致 packages 裡面仍然看到不該看的 function ,但透過 API document 的 <summary> 標記這個 setter 是供測試程式使用,其實影響外部的機率與使用相當低。對原本的 production code 來說,正常的執行流程也不會受到影響,只有增加判斷 static field 是否為空來決定簡單工廠內的流程。 最大的好處,當然就是 production code 並沒有為了可測試性,而把原本簡單工廠就可以搞定的需求,弄成抽象工廠+依賴注入工廠實體的方式,可想而知多了那些不必要的中介層(抽象工廠),產品程式碼會顯得多麼不直覺。 然而這方式也不是全然沒問題,因為當 parallel 執行測試時,簡單工廠的 static setter 就會出現 race condition 可能導致測試不穩定。不過 isolated unit test 執行速度相當快,執行測試是否要 parallel 執行控制權也在 developer 身上,因此這個小技巧在實務上還是有很高的實用價值。 希望這一篇的重構過程,以及用最小的成本針對 legacy code 進行 isolated unit test 對各位在面對 legacy code 能有所幫助。 Reference |