本章探討為了可測試性而改變設計的基本概念與技術,包含設計的利弊分析、替代方案,以及難以測試的設計範例。

為什麼要在設計中考慮可測試性#

改變程式碼設計以提升可測試性,對許多開發者來說是具有爭議的議題。在一個可測試的設計中,每段包含邏輯的程式碼(迴圈、if 判斷、switch 等)都應該讓撰寫單元測試變得簡單快速。

一個好的單元測試應具備 FICC 四項特性:

特性說明
Fast(快速)執行速度快
Isolated(隔離)可獨立執行,不依賴其他測試的順序
Configuration-free(免設定)不需要外部設定檔
Consistent(一致)每次執行都得到相同的通過或失敗結果

如果撰寫這樣的測試很困難,或需要花費大量時間,就代表系統不具備可測試性。

如果你把測試當作系統的「另一種使用者」來看待,為可測試性設計就成為一種自然的思維方式。在 TDD 中,測試先行,測試本身就決定了 API 的設計形態。

可測試性的設計目標#

作者以 Robert C. Martin 的物件導向設計原則(SOLID)作為基礎,提出了多項讓程式碼更具可測試性的設計準則。這些準則的核心理念都是在程式碼中保留 seam(接縫)——也就是可以注入或替換行為的切入點,而不必修改原始類別。

方法預設為 virtual#

在 .NET 中,方法預設不是 virtual 的,這意味著你無法在衍生類別中覆寫它們。將方法設為 virtual,可以讓測試時透過繼承並覆寫方法來改變行為,或者斷開對外部依賴的呼叫。

另一種替代做法是讓類別使用 delegate,測試時可以從外部替換這個 delegate:

public class MyOverridableClass
{
    public Func<int, int> calculateMethod = delegate(int i)
                                            {
                                                return i * 2;
                                            };

    public void DoSomeAction(int input)
    {
        int result = calculateMethod(input);
        if (result == -1)
        {
            throw new Exception("input was invalid");
        }
        // do some other work
    }
}

測試時可以直接替換 delegate 來模擬回傳值:

[Test]
[ExpectedException(typeof(Exception))]
public void DoSomething_GivenInvalidInput_ThrowsException()
{
    MyOverridableClass c = new MyOverridableClass();
    int SOME_NUMBER = 1;

    // stub the calculation method to return "invalid"
    c.calculateMethod = delegate(int i) { return -1; };

    c.DoSomeAction(SOME_NUMBER);
}

使用介面導向設計#

辨識應用程式中的「角色」並將其抽象為介面,是設計過程中的重要環節。抽象類別不應直接呼叫具體類別,具體類別之間也不應互相呼叫(除非是純資料物件)。透過介面,你可以在測試中用自己的 stub 或 mock 來替換系統中的依賴。

類別預設不為 sealed#

如果一個類別被標記為 sealed(Java 的 final),你就無法繼承它,也無法覆寫其中的 virtual 方法。雖然有時出於安全考量必須 seal 類別,但預設應保持 non-sealed

避免在含邏輯的方法內直接 new 具體類別#

在包含邏輯的方法中直接實例化具體類別,會讓測試難以控制該實例。如果方法依賴一個 logger,不要在方法內直接 new 它,而應透過以下方式取得:

  • 使用 factory method,並讓該 factory method 為 virtual,以便測試時覆寫
  • 透過 DI(依賴注入),在建構子中注入介面

避免直接呼叫靜態方法#

靜態方法在靜態語言中很難被替換。作者建議:

  • 使用 Extract and Override 重構手法,將靜態方法呼叫抽取出來
  • 更極端的做法是完全避免使用靜態方法,讓每段邏輯都屬於某個實例,從而更容易替換
  • 盡量減少 singleton 和靜態方法的數量

靜態方法之所以難以替換,是因為它們充當公共共享資源——無法被覆寫、無法被繼承替換。這也是許多 TDD 實踐者不喜歡 singleton 的原因之一。

避免在建構子和靜態建構子中放邏輯#

基於設定的類別(configuration-based classes)常被做成靜態類別或 singleton,導致很難在測試中替換。解決方式是使用 IoC 容器(Inversion of Control Container),例如 Microsoft Unity、Autofac、Ninject、StructureMap、Spring.NET、Castle Windsor 等。

IoC 容器提供一種類似「智慧工廠」的功能:你在建構子中要求一個介面,容器就自動為你提供匹配的實例,而你不需要知道底層是 singleton 還是新建的物件。

將 singleton 邏輯從 singleton holder 分離#

如果設計中需要使用 singleton,應將業務邏輯singleton 管理邏輯分開到兩個類別,以遵循單一職責原則(SRP)。

不可測試的 singleton 設計:

public class MySingleton
{
    private static MySingleton _instance;

    public static MySingleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new MySingleton();
            }
            return _instance;
        }
    }
}

重構為可測試的設計——將邏輯移到獨立類別,singleton holder 只負責管理實例生命週期:

public class RealSingletonLogic
{
    public void Foo()
    {
        // lots of logic here
    }
}

public class MySingletonHolder
{
    private static RealSingletonLogic _instance;

    public static RealSingletonLogic Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new RealSingletonLogic();
            }
            return _instance;
        }
    }
}
classDiagram
    direction LR
    class MySingleton {
        <<重構前>>
        -static MySingleton _instance
        +static Instance MySingleton
        +Foo()
    }
    class RealSingletonLogic {
        <<業務邏輯>>
        +Foo()
    }
    class MySingletonHolder {
        <<生命週期管理>>
        -static RealSingletonLogic _instance
        +static Instance RealSingletonLogic
    }
    MySingletonHolder --> RealSingletonLogic : 管理實例

設計準則與效益總覽:

  • 方法設為 virtual — 允許在衍生類別中覆寫方法以替換行為
  • 使用介面 — 透過多型替換依賴
  • 類別不為 sealed — 允許繼承和覆寫
  • 避免在邏輯中 new 具體類別 — 避免被綁定在內部實作上
  • 避免靜態方法呼叫 — 靜態方法無法被覆寫
  • 建構子不放邏輯 — 簡化從類別繼承的工作
  • 分離 singleton 邏輯 — 讓 singleton 可被替換或重設

為可測試性設計的利弊#

工作量#

為可測試性設計通常意味著寫更多程式碼。但你可以這樣看待它:額外的設計工作揭露了你原本可能忽略的設計問題(關注點分離、單一職責原則等)。

另一方面,你也可以主張測試程式碼和生產程式碼一樣重要,因為它暴露了 API 的使用特性,迫使你思考他人會如何使用你的程式碼。

複雜度#

為可測試性設計有時會讓人覺得過度複雜——你可能會加入一些感覺不太自然的介面,或暴露你原本不打算公開的類別行為語義。當專案有很多介面且都被抽象化時,要找到某個方法的真實實作也會變得較困難。

使用 ReSharper 等導覽工具可以大幅緩解這個問題。合適的工具能讓介面導覽變得容易。

暴露敏感智財#

有些專案包含不應被暴露的敏感智慧財產權,例如安全性資訊、授權機制或專利演算法。雖然有變通方式(如使用 [InternalsVisibleTo] 屬性),但這些做法本質上是在規避可測試性設計的理念。當涉及安全或專利問題時,你可能不得不在做法上做出妥協。

有時無法做到#

有時候出於政治或組織因素,你無法改變或重構設計。專案可能太脆弱不適合重構,或是環境本身就阻止你這麼做。這正是第 9 章所討論的影響因素的實例。

為可測試性設計的替代方案#

動態型別語言的觀點#

在 Ruby 或 Smalltalk 等動態語言中,程式碼天生具備可測試性,因為你可以在執行時替換任何東西。你不需要介面來替換依賴,也不需要將方法設為 public 才能覆寫。你甚至可以動態改變核心型別的行為。

在這樣的語言中,「為可測試性設計」的需求大幅降低,你可以更自由地選擇自己的設計方式。

設計論點#

作者觀察到,在 Ruby 社群中,人們最初並不需要為了可測試性改變設計(因為程式碼本來就可測試),後來他們發現了關於程式碼設計本身的重要性——這暗示著設計是一個獨立的活動,其意義超越了單純的可測試性重構。

回到靜態型別語言:不可測試設計的主要問題在於無法在執行時替換依賴。這就是為什麼需要建立介面、讓方法為 virtual 等。但也有工具可以幫忙解決——非受限的隔離框架(unconstrained isolation frameworks)可以在不重構的情況下替換 .NET 程式碼中的依賴。

非受限隔離框架的存在,並不意味著你就不需要設計良好的程式碼。可測試性不應該是設計目標,良好的設計本身才是目標。遵循 SOLID 原則、物件導向設計模式所帶來的好處——程式碼更易維護、更易閱讀、更易開發——即使可測試性不再是問題,這些好處依然存在。

難以測試的設計範例#

作者以開源專案 BlogEngine.NETPing 命名空間下的 Manager 類別為例,展示一個完全靜態、難以測試的設計。

這個 Manager 類別的 Send 方法存在以下問題:

  • 所有依賴都是靜態方法,無法在不使用非受限框架的情況下偽造或替換
  • 依賴無法注入,它們是直接被使用的,沒有透過參數或屬性傳入
  • 無法使用 Extract and Override,因為 Manager 類別本身就是 static,不能包含非靜態方法或 virtual 方法
  • 即使類別不是 static,要測試的方法本身也是 static,無法直接呼叫 virtual 方法

作者提出的重構步驟(假設已有整合測試保護):

  1. 移除類別的 static 修飾符
  2. 建立一個實例方法(如 InstanceSend),包含與原靜態方法相同的參數但非 static
  3. 讓原始靜態方法委派給實例方法new Manager().Send(item, itemUrl);,確保所有現有呼叫端不受影響
  4. 對實例方法使用 Extract and Override 來斷開依賴,例如將 BlogSettings.Instance.EnableTrackBackSend 的呼叫抽取成可覆寫的 virtual 方法
  5. 持續重構,逐步抽取並覆寫更多依賴
flowchart TD
    A["1. 移除類別的 static 修飾符"] --> B["2. 建立實例方法"]
    B --> C["3. 讓靜態方法委派給實例方法"]
    C --> D["4. 對實例方法使用 Extract and Override"]
    D --> E["5. 持續重構"]

讓方法更具可測試性的一般原則:

  • 預設類別為非靜態(C# 中很少有理由使用純靜態類別)
  • 方法預設為實例方法而非靜態方法

總結#

  • 可測試設計主要在靜態型別語言(如 C# 或 VB.NET)中才重要,因為可測試性取決於主動的設計選擇
  • 動態語言中,大部分東西都可以輕易替換,無論設計如何,因此可測試性的設計壓力較低
  • 可測試的設計具備 virtual 方法、non-sealed 類別、介面、關注點分離,以及更多實例類別而非靜態類別
  • 可測試設計與 SOLID 設計原則高度相關,但通過可測試性檢驗不代表設計一定是好的
  • 最終目標不應只是可測試性,而應是良好的設計本身

額外資源#

作者推薦的延伸閱讀與學習資源:

  • Growing Object-Oriented Software, Guided by Tests — Steve Freeman & Nat Pryce 著,從設計角度補充本書主題
  • xUnit Test Patterns: Refactoring Test Code — Gerard Meszaros 著,單元測試模式與反模式的參考書
  • Working Effectively with Legacy Code — Michael Feathers 著,處理遺留程式碼的必讀書籍
  • 作者也建議探索 **BDD(行為驅動開發)**風格的框架,如 MSpec 或 NSpec,它們提供了另一種組織測試的方式
  • 在成長的過程中,開發者往往會從單元測試逐步擴展到自動化整合測試與系統測試,以獲得更全面的品質信心