iT邦幫忙

2021 iThome 鐵人賽

DAY 20
0

在方法被呼叫前注入一個假物件-前言 (以工廠類別為示範)

今天接下來會探討第三種型別,並非透過建構函式或屬性注入的方式建置假物件,而且在對被測試物件進行操作前才獲得該物件的執行個體。因此,接下來會引用單元測試的藝術中提到以工廠類別的例子做示範,所以在這之前要先對工廠類別做個簡單的概述,而這邊以Yan(硯取歪)撰寫的 工廠模式 Factory Pattern 為參考來源。

那為了因應中秋節,我們就以月餅做為工廠類別的範例吧XD,以下為工廠類別要實作的月餅產品:

// 月餅 (MoonCake)
public interface MoonCake 
{
    string moonCakeType();
}

// 月餅-原味 (TraditionalMoonCake)
public class TraditionalMoonCake : MoonCake 
{
    public string moonCakeType() {
        return "原味月餅";
    }
}

// 月餅-芋頭 (TaroMoonCake)
public class TaroMoonCake : MoonCake 
{
    public string moonCakeType() {
        return "芋頭月餅";
    }
}

以下則是工廠介面與工廠實作類別:

// 月餅工廠
public interface MoonCakeFactory
{
    public MoonCake generateMoonCake();
}

// 月餅工廠-原味 (TraditionalMoonCake)
public class TraditionalMoonCakeFactory : MoonCakeFactory
{
    public MoonCake generateMoonCake() {
        return new TraditionalMoonCake();
    }
}

// 月餅工廠-芋頭 (TaroMoonCake)
public class TaroMoonCakeFactory : MoonCakeFactory
{
    public MoonCake generateMoonCake() {
        return new TaroMoonCake();
    }
}

簡單測試工廠類別產製的月餅:

public class DemoMoonCakeFactoryTest {
    [Test]
    public void test(){
        // Arrange
        TaroMoonCakeFactory taroMCFactory = new TaroMoonCakeFactory();

        // Act
        var taroSample = taroMCFactory.generateMoonCake();

        // Assert
        Assert.AreEqual("芋頭月餅", taroSample.moonCakeType());
    }
}

那簡單看完工廠模式,那接下來可以來看工廠模式對於昨天範例的寫法。


看程式碼說故事 (Refactoring & Seam-2)

那一樣,我們先來看昨天 LogAnalyzer 的原始碼,只是這次的方式是要用工廠類別的方式新增;於是乎,程式碼改寫如下:

public class LogAnalyzer
{
    private IExtensionManager Manager;
    
    public LogAnalyzer()
    {
        Manager = ExtensionManagerFactory.Create();
    }

    public bool IsValidLogFileName(string fileName)
    {
        return Manager.IsValid(fileName);
    }
}

public interface IExtensionManager
{
    bool IsValid(string fileName);
}

public class FileExtensionManager
{
    public bool IsValid(string fileName)
    {
        // Read some file here
    }
}

public class ExtensionManagerFactory
{
    private IExtensionManager CustomManager = null;
    
    public IExtensionManager Create()
    {
        return new FileExtensionManager();
    }
    
    public void SetManager(IExtensionManager inCustomManager)
    {
        CustomManager = inCustomManager;
    }
}

透過工廠模式,我們把平常使用 FileExtensionManager 物件與做測試可做為接縫的 SetManager 方法都設想好了,這樣的話撰寫測試就可抽換成虛設常式,如下:

[TestFixture]
public class LogAnalyzerUnitTests
{
    [Test]
    public void DemoFactoryTest()
    {
        // Arrange
        var manager = Substitute.For<IExtensionManager>();
        
        manager.IsValid(default).ReturnsForAnyArgs(true);
        
        var ExtensionManagerFactory = new ExtensionManagerFactory();
        
        ExtensionManagerFactory.SetManager(manager);
        
        var log = new LogAnalyzer();
        
        // Act
        bool result = log.IsValidLogFileName("short.ext");
        
        // Assert
        Assert.True(result);
    }
}

如此一來,透過工廠模式的方式,就可以在工廠模式裡面的 SetManager 抽換檔案系統,改寫成虛設常式。那在這邊會提到第三種方式—在方法被呼叫前注入一個假物件,其原因在於許多已開發的專案伴隨著不同的設計模式(又或是根本沒有),因應不同的情況我們要思索出相對應的接縫點,以這個用工廠模式的範例,其實有好幾個開設接縫點的策略,如被測試類別撰寫建構函式、屬性注入又或是在工廠模式新設方法注入。

此外,依據不同的接縫策略,所採取的策略也不一樣,單元測試的藝術以 Day-19 與 Day-20 的範例提供了三種不同的中間層深度等級(撰寫接縫的地方),見以下表單:

被測試類別 可以進行的操作
層次深度 1:針對類別的 FileExtensionManager 類別 新增建構函式或屬性注入,被測試類別僅一個成員被偽造。
層次深度 2:針對工廠注入被測試類別的相依物件 透過工廠類別的賦值方法設定相對應假的相依物件。此時僅工廠內的成員為偽造的,被測試類別不需要調整。
層次深度 3:偽造假工廠類別 建立假的工廠類別,如此裡面所有的工廠方法都可依自己意思撰寫,可抽離相依物件的掌握度是最高的,但相對應撰寫成本極高且使測試變複雜。

PS:作者未提供第三種寫法,其原因在於實務上工廠類別的方法極多,若所有的方法都撰寫假方法,僅只用其中的三四成方法又或是更少,其效益極低。


看程式碼說故事 (Refactoring & Seam-3)

除了上述提到的三種層次以外,那作者提供了第四種方式(不屬於以上三種其中一種):使用一個區域的工廠方法(擷取與覆寫),顧名思義,先在被測試類別寫一隻區域的工廠方法;而要撰寫測試的時候,先寫一隻繼承自被測試類別的類別(好像繞口令XD)。

然後再這支類別裡面新增建構函式(又或是屬性注入),讓假物件可注入進去,程式碼如下:

public class LogAnalyzerUsingFactoryMethod
{
    public bool IsValidLogFileName(string fileName)
    {
        return GetManager().IsValid(fileName);
    }
    
    public virtual IExtensionManager GetManager()
    {
        return new FileExtensionManager();
    }
}

public class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod
{
    private IExtensionManager Manager;
    
    public TestableLogAnalyzer(IExtensionManager inManager)
    {
        Manager = inManager;
    }

    protected override IExtensionManager GetManager()
    {
        return Manager;
    }
}

public interface IExtensionManager
{
    bool IsValid(string fileName);
}

public class FileExtensionManager
{
    public bool IsValid(string fileName)
    {
        // Read some file here
    }
}

測試碼:

[TestFixture]
public class LogAnalyzerUnitTests
{
    [Test]
    public void DemoExtractAndOverrideTest()
    {
        // Arrange
        var manager = Substitute.For<IExtensionManager>();
        
        manager.IsValid(default).ReturnsForAnyArgs(true);
        
        var log = new TestableLogAnalyzer(manager);
        
        // Act
        bool result = log.IsValidLogFileName("short.ext");
        
        // Assert
        Assert.True(result);
    }
}

這個手法的好處在於,如果原本的程式碼沒有適當的接縫點,新增繼承的類別手法可避免原生的程式碼;然而,若原本的程式碼已經有適當的接縫點,且具備可測試性的性質,這樣就反而多此一舉。所以,接縫也算是單元測試中一門很重要的學問,寫出好的接縫點也是需要經驗累積。


上一篇
Day 19-重構 (Refactoring) 與接縫 (Seam) - 1 (核心技術-11)
下一篇
Day 21-Unit Test 應用於 Web APIs (情境及應用-1)
系列文
單元測試從入門到進階之路 (以 C# NUnit 3 X NSubstitute 為例)30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言