Dependency-Breaking Techniques#

本章是一份 dependency-breaking techniques 的完整目錄,共包含 24 種技巧。每種技巧都旨在幫助你打破 legacy code 中的依賴,讓程式碼能夠被放入 test harness 中進行測試。


1. Adapt Parameter#

當你無法在測試中使用某個方法的參數型別時——可能因為該型別是由 vendor 提供、建構成本太高,或依賴了難以在測試中重現的基礎設施——你可以建立一個 wrapper 來適配它。

適用時機:

  • 參數型別是你無法輕易在測試中建立的(例如 framework 或 vendor 提供的物件)
  • 你無法使用 Extract Interface 因為你不擁有該型別

步驟:

  1. 建立新的 interface,使其包含你在方法中需要使用的參數方法
  2. 建立一個 adapter class 實作該 interface,並持有原始參數的實例,將呼叫委派給它
  3. 建立一個 fake class 也實作該 interface,用於測試
  4. 修改方法的參數型別為新的 interface

2. Break Out Method Object#

當你有一個非常長的方法且無法輕易提取部分程式碼時,可以將整個方法搬到一個新的 class 中。方法的參數變成 constructor 的參數,方法的本體進入新 class 的一個名為 runexecute 的方法中。

適用時機:

  • 方法很長且難以測試
  • 你想把方法中的臨時變數變成 instance variables 以便 sensing
  • 原始 class 的依賴使其難以實例化

步驟:

  1. 建立一個新 class,名稱基於原始方法的用途
  2. 為原始方法的每個參數以及原始物件的參考建立 constructor 參數。使用 Preserve Signatures 來降低風險
  3. 為原始方法中使用的每個 local variable 和 temporary variable 建立 instance variable
  4. 建立一個 run 方法,將原始方法的本體複製進去
  5. 在原始方法中建立新 class 的實例並呼叫 run

Figure 25.1: GDIBrush after Break Out Method Object


3. Definition Completion#

在 C 和 C++ 中,你可以將型別的宣告(declaration)和定義(definition)分開。你可以利用這一點,在測試檔案中提供替代的定義。

適用時機:

  • 在 C/C++ 中,你需要替換某個型別的具體實作
  • 你想在測試中提供不同的行為

步驟:

  1. 確認你想替換的型別的宣告
  2. 在測試檔案中提供該型別的替代定義
  3. 確保 build 設定讓測試使用替代定義而非原始定義

4. Encapsulate Global References#

當你有多組全域變數或全域函式被一組方法使用時,可以將它們包進一個 class 中。這讓你可以使用 Subclass and Override Method 等技巧來替換它們。

適用時機:

  • 全域變數或函式使得程式碼難以測試
  • 多個全域項目在概念上屬於同一個群組

步驟:

  1. 確認你想封裝的全域變數和函式
  2. 建立一個 class,把它們作為 instance variable 和 method
  3. 如果全域變數只有一組合理的值,考慮使用 Singleton(但要讓它可被測試替換)
  4. 將所有對全域變數的引用改為對新 class 實例的引用
  5. 使用 Lean on the Compiler 來找到所有需要修改的地方

5. Expose Static Method#

如果你有一個方法不使用任何 instance data,你可以把它變成 static。這樣在測試中就不需要實例化整個 class,可以直接呼叫 static method。

適用時機:

  • 方法不依賴任何 instance state
  • 實例化所屬的 class 非常困難

步驟:

  1. 確認該方法不引用任何 instance variable 或 instance method
  2. 將方法宣告為 static
  3. 編譯,確認沒有問題
  4. 在測試中直接透過類別名稱呼叫該方法

Expose Static Method 並不是理想的設計。它破壞了封裝,讓方法可以在沒有物件的情況下被呼叫。但它是一個有用的中間步驟——先讓程式碼可測試,之後再改善設計。


6. Extract and Override Call#

當你的方法中有一個對其他物件的呼叫導致了依賴問題,你可以將該呼叫提取到自己的方法中,然後在測試用的 subclass 中 override 它。

適用時機:

  • 方法中有一個特定的呼叫(例如對外部 API、資料庫等)需要在測試中替換
  • 你想快速建立 seam

步驟:

  1. 找到你想替換的呼叫
  2. 將該呼叫提取成一個新方法
  3. 在測試中建立一個 subclass,override 該新方法以提供 fake 行為

7. Extract and Override Factory Method#

如果在 constructor 中建立了難以測試的物件,你可以將物件建立提取到 factory method 中,然後在測試用的 subclass 中 override 它。

適用時機:

  • Constructor 中有 new 表達式建立了難以在測試中使用的物件
  • 你想替換 constructor 中建立的物件但不想修改 constructor 簽名

步驟:

  1. 將物件建立提取成一個 factory method
  2. 在測試中建立 subclass,override 該 factory method 以回傳 fake 物件

在 C++ 中要小心在 constructor 中呼叫 virtual method——C++ 不會 dispatch 到 derived class 的 override。Java 和 C# 則可以正常運作。


8. Extract and Override Getter#

類似 Extract and Override Factory Method,但使用 lazy initialization 的 getter。不在 constructor 中建立物件,而是在第一次被需要時透過 getter 建立。

適用時機:

  • 你想避免在 constructor 中建立物件的問題
  • 語言不支援在 constructor 中呼叫 virtual method(如 C++)

步驟:

  1. 找到你想替換的 instance variable
  2. 建立一個 getter method,使用 lazy initialization
  3. 將所有對該 variable 的直接引用改為呼叫 getter
  4. 建立測試用 subclass,override getter 回傳 fake 物件

作者不太喜歡 lazy initialization,因為它違反了「物件在建構完成後即處於有效狀態」的原則。建議只在確實需要時使用。


9. Extract Implementer#

當你有一個 class 你想 mock,但它不實作任何 interface 且直接在很多地方被使用時,你可以將它的實作推到一個新 class 中,讓原始 class 變成 interface。

適用時機:

  • 該 class 被很多地方直接引用,你不想到處修改型別宣告
  • 你想要一個 interface 但改名代價太大

步驟:

  1. 將原始 class 複製為新 class(例如加上 Impl 後綴)
  2. 將原始 class 改為 interface,刪除所有方法體和 instance variable
  3. 讓新 class 實作原始 interface
  4. 編譯並修正所有需要使用具體 class 的地方(如 new 呼叫)

Figure 25.2: ModelNode with superclass and subclass

Figure 25.3: After Extract Implementer on Node

Figure 25.4: Extract Implementer on ModelNode


10. Extract Interface#

建立一個 interface,包含你在測試中需要使用的方法。讓原始 class 實作該 interface,並在測試中建立 fake class 也實作該 interface。

適用時機:

  • 你想 mock 一個 class 以便進行 sensing 或 separation
  • 你不需要 fake class 中的所有方法,只需要其中幾個

步驟:

  1. 建立新的 interface,給它一個好名字
  2. 讓目標 class 實作該 interface,暫時不要為 interface 加入任何方法
  3. 修改你想測試的地方,使用 interface 型別而非具體型別
  4. 編譯——編譯錯誤會告訴你哪些方法需要加到 interface 中

為 interface 命名時不要只是在 class 名稱前加 I。想想這組方法的本質是什麼,給它一個有意義的概念名稱。好的命名能提升設計品質。

Figure 25.5: PaydayTransaction depending on TransactionLog


11. Introduce Instance Delegator#

當 class 上有 static method 導致測試困難時(因為 static cling——對 static method 的依賴無法透過 object seam 替換),你可以新增一個 instance method 來委派給 static method。

適用時機:

  • 你的程式碼依賴 utility class 的 static method
  • 你需要在測試中替換這些行為

步驟:

  1. 找到造成問題的 static method
  2. 在同一個 class 上建立一個 instance method,讓它委派給 static method
  3. 在呼叫端改為使用 instance method(需要有該 class 的實例)
  4. 使用 Parameterize Method 或其他技巧將實例傳入需要它的地方

隨著時間推移,當所有呼叫都透過 instance method 時,你可以把 static method 的本體搬到 instance method 中,並刪除 static 版本。


12. Introduce Static Setter#

當你有全域 mutable data(通常是 Singleton)阻礙了測試,你可以新增一個 static setter 來替換該 instance,讓測試能注入 fake 物件。

適用時機:

  • 你需要替換 Singleton 或其他全域物件以進行測試
  • 全域狀態使得 test harness 難以設定

步驟:

  1. 降低 constructor 的 protection level(改為 protected),以便 subclass
  2. 新增一個 static setter,接受該 singleton class(或其 interface)的引用。確保 setter 會在設定新物件前正確銷毀舊 instance
  3. 如果需要存取 singleton 的 private/protected method 來設定測試,考慮 subclass 或 extract interface

這種技巧確實會弱化 access protection。但記住——access protection 的目的是防止錯誤,而我們在測試中做的也是防止錯誤,只是需要更強的工具。在程式碼中留下註解說明這是為了測試目的。

對於 global factory(回傳新物件而非持有實例的 static method),可以讓 factory 委派給一個可替換的 server 物件。

記住在使用 static setter 時,你修改的是所有測試共享的狀態。使用 xUnit framework 的 setUptearDown 方法來確保狀態被正確清理。


在程序式語言(如 C)中,物件導向的替換方式不可用。但你可以透過在連結時替換函式來達到類似效果——建立一個 dummy library,包含與你要 fake 的函式相同 signature 的函式。

適用時機:

  • 你在使用 C 等程序式語言
  • 你想 fake 外部函式庫的呼叫
  • 最適合 fake 的函式庫是那些主要作為 data sink 的(你呼叫其中的函式,但不太關心回傳值)

步驟:

  1. 確認你想 fake 的函式或 class
  2. 提供替代的定義
  3. 調整 build 設定,讓測試時使用替代定義而非正式版本

Link Substitution 也可以用在 Java 中——建立具有相同名稱和方法的 class,修改 classpath 讓呼叫解析到你的替代版本。


14. Parameterize Constructor#

如果 constructor 中建立了你想替換的物件,最簡單的方式是將該物件從外部建立,作為參數傳入 constructor。

適用時機:

  • Constructor 中有 new 表達式你想替換
  • 你想做 dependency injection

步驟:

  1. 找到你想參數化的 constructor 並複製一份
  2. 為複製版新增一個參數,用於接收你想替換的物件。移除物件建立,改為從參數賦值
  3. 如果語言支援 constructor 互相呼叫,讓原始 constructor 呼叫新 constructor,並在呼叫中使用原始的 new 表達式
public class MailChecker {
    public MailChecker(int checkPeriodSeconds) {
        this(new MailReceiver(), checkPeriodSeconds);
    }

    public MailChecker(MailReceiver receiver, int checkPeriodSeconds) {
        this.receiver = receiver;
        this.checkPeriodSeconds = checkPeriodSeconds;
    }
}

這樣既可以在測試中傳入 fake 物件,又不影響現有的 client code。


15. Parameterize Method#

類似 Parameterize Constructor,但適用於方法。如果方法內部建立了你想替換的物件,將該物件作為參數傳入。

適用時機:

  • 方法內部建立了你想在測試中替換的物件
  • 你想從外部控制方法使用的物件

步驟:

  1. 找到你想替換的方法並複製一份
  2. 為複製版新增一個參數,用於接收要替換的物件,移除內部的物件建立
  3. 刪除原始方法的本體,改為呼叫參數化版本(傳入原始的 new 表達式)

16. Primitivize Parameter#

當一個 class 因為龐大的依賴鏈而極難放入 test harness 時,你可以用原始型別(primitive)的表示方式來繞過複雜的依賴。建立一個 free function(不屬於任何 class 的函式),用原始型別的資料結構做計算,然後在原始 class 上新增一個方法來委派給它。

適用時機:

  • Class 的依賴鏈太深,無法合理地放入 test harness
  • 你被逼到牆角,需要先做出功能再回頭改善

步驟:

  1. 開發一個 free function,用原始型別表示做你需要的工作。在過程中建立一個中間表示
  2. 在原始 class 上新增一個方法,建構中間表示並委派給新函式

Primitivize Parameter 會讓程式碼處於不太好的狀態——它暴露了內部表示、複製了資料、產生了未測試的程式碼。它是 Sprout Class 的前身。只有在確信你之後會花時間讓 class 可測試並把函式整合回去時,才使用這個技巧。


17. Pull Up Feature#

有時你需要使用 class 上的一組方法,但 class 的依賴阻止你實例化它——而那些依賴與你想測試的方法無關。你可以把那組方法上移到一個 abstract superclass,然後在測試中 subclass 那個 superclass。

適用時機:

  • 你想測試的方法群組不直接或間接引用任何「壞」依賴
  • 使用 Expose Static MethodBreak Out Method Object 不夠直接

步驟:

  1. 建立一個 abstract superclass
  2. 將你想測試的方法和它們使用的 instance variable 從原始 class 複製到 superclass
  3. 讓原始 class 繼承 superclass,刪除原始 class 中已移到 superclass 的方法和變數
  4. 在測試中建立一個 concrete subclass(提供剩餘 abstract method 的空實作),用它來測試那些方法

18. Push Down Dependency#

如果 class 中只有少數幾個依賴造成問題,你可以把那些依賴下推到一個 subclass,讓原始 class 變成 abstract class。然後在測試中建立另一個 subclass 提供 fake 實作。

適用時機:

  • 只有少數方法包含難以處理的依賴
  • 那些依賴出現在 class 的具體方法中

步驟:

  1. 找出哪些方法包含「壞」依賴
  2. 建立一個 subclass,將那些方法搬到 subclass 中
  3. 讓原始 class 中的那些方法變成 abstract
  4. 建立測試用 subclass,提供 fake 實作
  5. 在測試中使用測試 subclass

19. Replace Function with Function Pointer#

在 C 中,你可以用 function pointer 來替代直接呼叫函式,這讓你能在測試時替換函式的實作。

適用時機:

  • 在 C 程式中需要替換函式行為
  • 你無法使用 Link Substitution

步驟:

  1. 為每個你想替換的函式宣告一個 function pointer,使用相同的簽名
  2. 在初始化時將 function pointer 設定為指向原始函式
  3. 將所有對原始函式的呼叫改為透過 function pointer 呼叫
  4. 在測試中,將 function pointer 改為指向你的替代函式

20. Replace Global Reference with Getter#

如果 class 中有方法直接引用全域變數或 Singleton,你可以用一個可 override 的 getter method 來替代直接引用。

適用時機:

  • 方法中有對全域變數或 Singleton 的直接引用
  • 你想用 Subclass and Override Method 來替換它

步驟:

  1. 建立一個 getter method,回傳全域引用
  2. 將方法中所有對全域的直接引用替換為 getter 呼叫
  3. 在測試用 subclass 中 override getter 以回傳 fake 物件

21. Subclass and Override Method#

這是本書中最核心的 dependency-breaking technique 之一。在物件導向語言中,繼承是一個非常強大的工具。如果你有一個 class 的某些行為需要在測試中替換,你可以 subclass 它並 override 那些方法。

適用時機:

  • 幾乎任何需要在測試中替換行為的場景
  • 需要做 sensing 或 separation

步驟:

  1. 找到你想在測試中替換的方法(或先用 Extract and Override Call 等技巧提取出來)
  2. 讓該方法成為 virtual(如果在 C++ 中)或確保它可以被 override
  3. 建立一個 testing subclass
  4. Override 該方法,提供你在測試中需要的行為
public class TestingMessageForwarder extends MessageForwarder {
    @Override
    protected void sendMessage(Message message) {
        // 不實際發送,只記錄
        sentMessages.add(message);
    }
}

這個技巧可以與本章中幾乎所有其他技巧搭配使用。

Figure 25.6: TestingAccount superimposed on Account


22. Supersede Instance Variable#

如果你無法在 constructor 中替換物件(例如 constructor 中的物件建立包含重要的副作用或初始化邏輯),你可以新增一個 setter 方法,在建構之後替換該 instance variable。

適用時機:

  • Constructor 中的物件建立有重要的副作用,不能簡單用 Parameterize Constructor 處理
  • 你需要在建構之後替換依賴

步驟:

  1. 確認你想替換的 instance variable
  2. 建立一個方法(setter)來設定該 variable 的值
  3. 在測試中,先正常建構物件,然後呼叫 setter 替換為 fake 物件

盡量優先使用 Parameterize ConstructorExtract and Override Factory MethodSupersede Instance Variable 應該是最後手段,因為容易遺忘呼叫 setter 而導致測試使用了真實物件。


23. Template Redefinition#

在支援 template 或 generics 的語言中(如 C++),你可以將依賴的型別參數化,然後在測試中提供替代型別。

適用時機:

  • 你使用 C++ 等支援 template 的語言
  • 你想替換 class 使用的某個型別而不需要繼承

步驟:

  1. 將要替換的型別改為 template 參數
  2. 將原始 class 改為 template class
  3. 在測試中用 fake 型別實例化 template
  4. 在 production code 中用真實型別實例化 template

24. Text Redefinition#

在動態語言中(如 Ruby、Python),你可以在執行時重新定義方法。這讓打破依賴變得非常簡單——直接在測試設定中重新定義有問題的方法即可。

適用時機:

  • 你使用動態語言(Ruby、Python、JavaScript 等)
  • 你想快速替換方法行為

步驟:

  1. 找到你想替換的方法
  2. 在測試檔案中,在測試執行前重新定義該方法
  3. 確保重新定義只影響測試環境

動態語言讓 dependency breaking 更容易,但也意味著你失去了 compiler 的安全網。確保你有足夠的測試來捕捉型別錯誤。