本章探討當待測程式碼依賴於外部資源(檔案系統、Web 服務、時間等)時,如何透過 stub 來打破這些依賴,讓程式碼變得可測試。核心思路是:找到依賴的介面、加入一層間接層、用可控的替代品取代真實實作。

3.1 Stub 介紹#

作者以 NASA 太空梭模擬器為比喻:太空人不可能在真正的太空中接受測試,所以 NASA 打造了模擬器,用搖桿和螢幕模擬外部世界,讓太空人在受控環境中練習。單元測試的 stub 正是同樣的概念。

外部依賴(External Dependency):系統中你的待測程式碼會互動、但你無法控制的物件。常見範例包括檔案系統、執行緒、記憶體、時間等。

Stub:系統中某個既有依賴的可控替代品(也稱 collaborator)。使用 stub 可以在不直接碰觸依賴的情況下測試你的程式碼。

Stub 與 mock 的關鍵差異:你不會對 stub 做 assert,你對 stub 只是提供假的輸入值;而 mock 才是用來驗證互動行為的對象(第 4 章會詳細討論)。

3.2 辨識 LogAnalyzer 中的檔案系統依賴#

LogAnalyzerIsValidLogFileName 方法需要讀取檔案系統上的設定檔,來判斷某個副檔名是否被允許:

public bool IsValidLogFileName(string fileName)
{
    // 讀取設定檔
    // 如果設定檔說該副檔名被支援,就回傳 true
}

Figure 3.1: Your method has a direct dependency on the filesystem

這就是所謂的 test-inhibiting design(妨礙測試的設計):程式碼對外部資源有直接依賴,導致測試可能因為外部原因(設定檔不存在、磁碟故障等)而失敗,即使程式邏輯本身是正確的。在 legacy 系統中,一個工作單元可能有大量這類外部依賴。

3.3 決定如何輕鬆測試 LogAnalyzer#

打破依賴的模式源自太空梭模擬器的類比:

Figure 3.2: A space shuttle simulator with realistic joysticks and screens to simulate the outside world

將這個模式套用到程式碼上,有三個步驟:

  1. 找到介面:找到待測工作單元所依賴的介面或 API。在 LogAn 專案中,這就是檔案系統的設定檔。
  2. 加入間接層:如果依賴是直接連接的(直接呼叫檔案系統),就把存取檔案的程式碼抽到獨立的類別(如 FileExtensionManager),建立一層間接層。
  3. 替換底層實作:把 FileExtensionManager 替換成你能控制的 StubExtensionManager,讓測試程式碼掌控外部依賴的行為。
flowchart LR
    A["1. 找到介面<br/>識別依賴的 API"] --> B["2. 加入間接層<br/>抽取到獨立類別"]
    B --> C["3. 替換底層實作<br/>用 Stub 取代真實依賴"]

Figure 3.3: Introducing a layer of indirection to the design

Figure 3.4: Introducing a stub to break the dependency

替換後的實例不會真的碰檔案系統,從而打破了依賴。因為我們測的不是「與檔案系統互動的那個類別」,而是「呼叫那個類別的程式碼」,所以 stub 什麼都不做也完全沒問題。

3.4 重構設計使其更可測試#

本節介紹兩個重要術語:

重構(Refactoring):在不改變程式碼功能的前提下改變其結構。重構前後,程式碼做的事完全一樣,只是看起來不同。

接縫(Seam):程式碼中可以插入不同功能的地方,例如建構子參數、可設定的屬性、可覆寫的虛擬方法、可外部化的委派等。接縫是實踐**開放封閉原則(Open-Closed Principle)**的結果。

重構的兩種類型:

  • Type A:把具體物件抽象化為介面(interface)或委派(delegate)
  • Type B:讓假實作可以被注入到待測類別中

3.4.1 Extract Interface:擷取介面以允許替換實作#

第一步是把碰檔案系統的程式碼抽到獨立類別,再為該類別擷取出介面:

public class FileExtensionManager : IExtensionManager
{
    public bool IsValid(string fileName)
    {
        // 讀取檔案...
    }
}

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

接著建立一個實作該介面的 fake 類別:

public class AlwaysValidFakeExtensionManager : IExtensionManager
{
    public bool IsValid(string fileName)
    {
        return true;
    }
}

命名注意:作者建議用 Fake 而非 Stub 或 Mock 來命名這類替代物件(如 FakeExtensionManager),因為同一個 fake 未來可能被當作 stub 或 mock 使用。使用 AlwaysValidFakeExtensionManager 這樣的名稱,讓讀者不用看原始碼就能理解它的行為。

classDiagram
    class IExtensionManager {
        <<interface>>
        +IsValid(fileName) bool
    }
    class FileExtensionManager {
        +IsValid(fileName) bool
    }
    class FakeExtensionManager {
        +WillBeValid bool
        +IsValid(fileName) bool
    }
    class LogAnalyzer {
        -IExtensionManager manager
        +IsValidLogFileName(fileName) bool
    }
    IExtensionManager <|.. FileExtensionManager : 正式實作
    IExtensionManager <|.. FakeExtensionManager : 測試替身
    LogAnalyzer --> IExtensionManager : 依賴

3.4.2 依賴注入:將 fake 實作注入待測單元#

建立了介面和 fake 之後,需要一種方式把 fake 「塞進」待測程式碼中。常見的注入方式包括:

  • 透過建構子接收介面,存到欄位供後續使用
  • 透過屬性 get/set 注入
  • 方法呼叫前注入(參數傳遞、工廠類別、區域工廠方法)
mindmap
  root((依賴注入方式))
    建構子注入
      明確表達必要依賴
      API 語意清晰
    屬性注入
      依賴為可選
      更簡潔易讀
    工廠類別
      待測類別不變
      透過工廠 setter 替換
    Extract and Override
      覆寫 virtual 方法
      對程式碼改變最小

3.4.3 建構子注入(Constructor Injection)#

在建構子中加入介面型別的參數,讓測試時可以傳入 fake:

public class LogAnalyzer
{
    private IExtensionManager manager;

    public LogAnalyzer(IExtensionManager mgr)
    {
        manager = mgr;
    }

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

Figure 3.5: Flow of injection via a constructor

測試程式碼如下:

[Test]
public void IsValidFileName_NameSupportedExtension_ReturnsTrue()
{
    FakeExtensionManager myFakeManager = new FakeExtensionManager();
    myFakeManager.WillBeValid = true;

    LogAnalyzer log = new LogAnalyzer(myFakeManager);

    bool result = log.IsValidLogFileName("short.ext");
    Assert.True(result);
}

internal class FakeExtensionManager : IExtensionManager
{
    public bool WillBeValid = false;

    public bool IsValid(string fileName)
    {
        return WillBeValid;
    }
}

注意 fake 物件與先前的 AlwaysValidFakeExtensionManager 不同:它可以透過 WillBeValid 屬性從測試端設定回傳值,一個 fake 類別就能在多個測試案例中重複使用。

建構子注入的注意事項:當待測類別的依賴越來越多,建構子參數會不斷膨脹,降低可讀性和可維護性。解決方案包括:參數物件重構(Parameter Object Refactoring)——把所有依賴包成一個類別傳入;或使用 IoC 容器(如 Unity、StructureMap、Castle Windsor、Autofac、Ninject)自動處理依賴解析。

何時使用建構子注入:當你想明確告訴 API 使用者「這些依賴是必要的、不可省略的」時。如果依賴是可選的,請改用屬性注入。

3.4.4 模擬例外(Simulating Exceptions from Fakes)#

Fake 不只能回傳值,還能模擬拋出例外的情境:

internal class FakeExtensionManager : IExtensionManager
{
    public bool WillBeValid = false;
    public Exception WillThrow = null;

    public bool IsValid(string fileName)
    {
        if (WillThrow != null)
        {
            throw WillThrow;
        }
        return WillBeValid;
    }
}

測試時只需設定 WillThrow 屬性,就能驗證待測程式碼在面對例外時的行為。

3.4.5 屬性注入(Property Injection)#

透過公開的 property get/set 來注入依賴,待測類別在建構子中使用預設的真實實作:

public class LogAnalyzer
{
    private IExtensionManager manager;

    public LogAnalyzer()
    {
        manager = new FileExtensionManager();
    }

    public IExtensionManager ExtensionManager
    {
        get { return manager; }
        set { manager = value; }
    }

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

Figure 3.6: Using properties to inject a fake dependency

測試端透過屬性設定注入 fake:

LogAnalyzer log = new LogAnalyzer();
log.ExtensionManager = someFakeManagerCreatedEarlier;

何時使用屬性注入:當依賴是可選的,或者依賴有預設實例不會在測試中造成問題時。屬性注入比建構子注入更簡潔易讀。

和建構子注入的差異在於 API 設計的語意:建構子參數表達「這是必要的」;屬性則表達「這個依賴不是必要的,可以替換」。

3.4.6 在方法呼叫前注入(Inject Before Method Call)#

這類做法的特點是:由待測類別自己在內部取得依賴的實例,而非由外部設定。

使用工廠類別(Factory Class)#

待測類別在建構子中從工廠取得依賴,測試透過工廠的 setter 方法設定要回傳的 fake:

public class LogAnalyzer
{
    private IExtensionManager manager;

    public LogAnalyzer()
    {
        manager = ExtensionManagerFactory.Create();
    }

    public bool IsValidLogFileName(string fileName)
    {
        return manager.IsValid(fileName)
            && Path.GetFileNameWithoutExtension(fileName).Length > 5;
    }
}

class ExtensionManagerFactory
{
    private IExtensionManager customManager = null;

    public IExtensionManager Create()
    {
        if (customManager != null)
            return customManager;
        return new FileExtensionManager();
    }

    public void SetManager(IExtensionManager mgr)
    {
        customManager = mgr;
    }
}

Figure 3.7: A test configures the factory class to return a stub object

測試端:

ExtensionManagerFactory.SetManager(myFakeManager);
LogAnalyzer log = new LogAnalyzer();

使用靜態工廠時,務必在每個測試前後重設工廠狀態,避免測試之間互相影響。

不同的間接層深度#

層級做法說明
Layer 1:待測類別內部成員建構子注入類別內部的成員變成 fake,其他程式碼不變
Layer 2:工廠回傳的依賴設定工廠的屬性工廠內部的成員是 fake,待測類別完全不變
Layer 3:工廠類別本身替換整個工廠用 fake 工廠取代真實工廠

越深入底層,你的操控能力越強,但測試也越難理解。關鍵是找到可讀性與操控能力之間的平衡。

使用區域工廠方法(Extract and Override)#

在待測類別中建立 virtual 的工廠方法,測試時繼承該類別並覆寫工廠方法,回傳 fake:

Figure 3.8: Inheriting from the class under test to override its virtual factory method

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

    protected virtual IExtensionManager GetManager()
    {
        return new FileExtensionManager();
    }
}

// 測試用的衍生類別
class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod
{
    public IExtensionManager Manager;

    public TestableLogAnalyzer(IExtensionManager mgr)
    {
        Manager = mgr;
    }

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

測試程式碼:

[Test]
public void overrideTest()
{
    FakeExtensionManager stub = new FakeExtensionManager();
    stub.WillBeValid = true;

    TestableLogAnalyzer logan = new TestableLogAnalyzer(stub);

    bool result = logan.IsValidLogFileName("file.ext");
    Assert.True(result);
}

這種技巧稱為 Extract and Override,是一種強大且簡潔的依賴打破手法。

3.5 重構技巧的變體:用 Extract and Override 建立假結果#

Figure 3.9: Using Extract and Override to return a logical result instead of calling an actual dependency

除了覆寫工廠方法回傳 fake 物件,你也可以直接覆寫回傳邏輯結果的方法。在待測類別中,把包含依賴的邏輯抽成 virtual 方法,衍生類別直接覆寫回傳值,連 stub 物件都不需要:

public class LogAnalyzerUsingFactoryMethod
{
    public bool IsValidLogFileName(string fileName)
    {
        return this.IsValid(fileName);
    }

    protected virtual bool IsValid(string fileName)
    {
        FileExtensionManager mgr = new FileExtensionManager();
        return mgr.IsValid(fileName);
    }
}

// 測試用的衍生類別——直接回傳假結果
class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod
{
    public bool IsSupported;

    protected override bool IsValid(string fileName)
    {
        return IsSupported;
    }
}

測試程式碼更加簡潔:

[Test]
public void overrideTestWithoutStub()
{
    TestableLogAnalyzer logan = new TestableLogAnalyzer();
    logan.IsSupported = true;

    bool result = logan.IsValidLogFileName("file.ext");
    Assert.True(result);
}

何時使用 Extract and Override:適合用來模擬待測程式碼的輸入(回傳值、模擬介面)。但如果要驗證程式碼的輸出互動(例如是否正確呼叫了某個 Web 服務),Extract and Override 就不太適合,應改用 isolation framework(下一章的主題)。作者偏好在不需要新介面且類別未被 sealed 時優先使用此技巧,因為它對程式碼的語意改變最小。

3.6 克服封裝問題#

為了測試而新增建構子、setter、工廠等,看起來似乎違反了物件導向的封裝原則。作者對此的觀點很直接:測試是你 API 的第二種使用者,它和生產程式碼的使用者同等重要,只是目標不同。過度保護的設計(private 建構子、sealed 類別、不可覆寫的方法)是阻礙測試的常見原因。

作者提出 TOOD(Testable Object-Oriented Design) 的概念:在設計時就考慮可測試性,這與傳統的 OOD 觀點有時會衝突。以下是幾種在 release 模式中隱藏測試用接縫的技巧。

3.6.1 使用 internal 與 [InternalsVisibleTo]#

不想讓所有人看到額外的建構子?用 internal 取代 public,再用 [InternalsVisibleTo] 組件層級屬性把 internal 成員暴露給測試組件:

public class LogAnalyzer
{
    // ...
    internal LogAnalyzer(IExtensionManager extensionMgr)
    {
        manager = extensionMgr;
    }
    // ...
}

// 在 AssemblyInfo.cs 中
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AOUT.CH3.Logan.Tests")]

這是在沒有其他方式讓測試存取時的好選擇。

3.6.2 使用 [Conditional] 屬性#

System.Diagnostics.ConditionalAttribute 可以讓方法的呼叫端在特定 build flag 不存在時被移除:

[Conditional("DEBUG")]
public void DoSomething()
{
}

當 build flag 不是 DEBUG 時,所有對 DoSomething() 的呼叫都會被移除,但方法本身仍然存在。可用於只在 debug 模式下才被呼叫的 setter 方法。

[Conditional] 只能用在方法上,不能用在建構子上。被標注的方法本身不會從 production code 中隱藏,這與條件編譯不同。

3.6.3 使用 #if / #endif 條件編譯#

把測試專用的建構子或方法放在 #if DEBUG#endif 之間,只有在該 build flag 被設定時才會編譯:

#if DEBUG
    public LogAnalyzer(IExtensionManager extensionMgr)
    {
        manager = extensionMgr;
    }
#endif

條件編譯容易讓程式碼變得凌亂、降低可讀性。建議優先使用 [InternalsVisibleTo]

3.7 總結#

本章的核心要點:

  • Stub 是外部依賴的可控替代品,用來提供假的輸入給待測程式碼
  • 打破依賴的關鍵是找到正確的間接層(layer of indirection),或自己建立一個,然後把它當作**接縫(seam)**來注入 stub
  • 這些替代類別統稱為 fake,因為我們不想在建立時就決定它是 stub 還是 mock
  • 注入 stub 的方式有多種:建構子注入屬性注入工廠類別區域工廠方法(Extract and Override)
  • 間接層越深,操控能力越強,但測試也越難理解;越接近待測物件的表面,測試越容易理解和維護
  • Extract and Override 適合模擬輸入;若需要驗證物件之間的互動,應改用 interface 搭配 isolation framework
  • TOOD(Testable Object-Oriented Design) 允許在設計中保留測試接縫,同時透過 internal[InternalsVisibleTo][Conditional]、條件編譯等機制在 release 模式中隱藏這些接縫