在實務開發中,常使用簡單工廠(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/
