[Unit Test Tricks] Static Setter Injection

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

    }
[程式碼說明] 將訂單分成 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 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;
        }
    }
}
[程式碼說明] 在套用了策略模式簡單工廠後,出貨 service 的 context 流程與職責更加清楚了,針對傳入的每張訂單類型,從工廠取得對應的便利商店服務,進行出貨。 
請留意,我在這個重構的步驟中產生了一個 bug ,簡單工廠裡面的方法中,當訂單類別 == Family 時,回傳的 instance 寫成了回傳 sevenService 。 這是我在撰寫範例的過程中實際發生的例子,一直到執行測試時,我才發現測試結果是紅燈。因為相當真實,所以決定在這篇文章中,保留這個歷程。

在一切看似美好的時候,請回過頭看一開始的測試程式,需求並不會因為重構得多乾淨清楚而有所改變。當傳入三張訂單,其中一張為 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 。測試結果如下圖所示:  

enter image description here

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();
        }
    }

enter image description here

使用 static setter 來注入 stub/mock object 時,請記得一定要在每一次執行測試案例之前,將 static setter 清除掉,例如在 [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 能有所幫助。

 “Keep it simple, stupid.” 如果需求沒有這麼複雜,就不要為了可測試性而使得 production code 難以維護。

blog 與課程更新內容,請前往新站位置:http://tdd.best/