本章探討為了可測試性而改變設計的基本概念與技術,包含設計的利弊分析、替代方案,以及難以測試的設計範例。
為什麼要在設計中考慮可測試性#
改變程式碼設計以提升可測試性,對許多開發者來說是具有爭議的議題。在一個可測試的設計中,每段包含邏輯的程式碼(迴圈、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.NET 中 Ping 命名空間下的 Manager 類別為例,展示一個完全靜態、難以測試的設計。
這個 Manager 類別的 Send 方法存在以下問題:
- 所有依賴都是靜態方法,無法在不使用非受限框架的情況下偽造或替換
- 依賴無法注入,它們是直接被使用的,沒有透過參數或屬性傳入
- 無法使用 Extract and Override,因為
Manager類別本身就是 static,不能包含非靜態方法或 virtual 方法 - 即使類別不是 static,要測試的方法本身也是 static,無法直接呼叫 virtual 方法
作者提出的重構步驟(假設已有整合測試保護):
- 移除類別的 static 修飾符
- 建立一個實例方法(如
InstanceSend),包含與原靜態方法相同的參數但非 static - 讓原始靜態方法委派給實例方法:
new Manager().Send(item, itemUrl);,確保所有現有呼叫端不受影響 - 對實例方法使用 Extract and Override 來斷開依賴,例如將
BlogSettings.Instance.EnableTrackBackSend的呼叫抽取成可覆寫的 virtual 方法 - 持續重構,逐步抽取並覆寫更多依賴
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,它們提供了另一種組織測試的方式
- 在成長的過程中,開發者往往會從單元測試逐步擴展到自動化整合測試與系統測試,以獲得更全面的品質信心