在實務開發中,常使用簡單工廠(Simple Factory)以及策略模式(Strategy Pattern)來封裝實作細節,使得 context 流程抽象穩定,並達到開放封閉原則(Open/Close Principle, OCP)中所蘊含可抽換實作的彈性。
在 context 流程中,透過簡單工廠依據條件來取得 interface 的 instance 固然美好,卻往往因為與簡單工廠的 static function 直接耦合,而導致這段 context 流程無法進行 isolated unit test。
這篇文章的小技巧,就是要解決 developer 因可測試性而廢棄簡單工廠不用,反而大費周章改用抽象工廠(Abstract Factory Pattern)的問題。
前言
在上一篇文章 [Unit Test Tricks] Extract 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
}
上述的作法已經將兩個便利商店出貨的職責,分屬在 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 Pattern
Context 端則透過一個 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;
}
}
}
在一切看似美好的時候,請回過頭看一開始的測試程式,需求並不會因為重構得多乾淨清楚而有所改變。當傳入三張訂單,其中一張為 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();
}
}
}
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();
}
}
[TestInitialize]
重新 assign null 給 static setter ,這樣才可以確保測試案例之間沒有 side effect 。結論
不要為了可測試性,導致原本 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 能有所幫助。
blog 與課程更新內容,請前往新站位置:http://tdd.best/