一個好的單元測試要 FICC——快(Fast)、隔離(Isolated)、免設定(Configuration-free)、一致(Consistent)。如果你替某段邏輯寫測試很困難、很花時間,那不是測試的錯,而是設計不具可測試性。為可測試性而設計,就是在寫生產程式碼時就預留 seam(接縫):可以從外部注入或替換行為的切入點,讓每個
if/迴圈/switch都能被便宜地測到。把測試當成系統的「另一種使用者」,這件事就變得自然。
🧠 Why It Matters
- 測試是另一種使用者:在 TDD 裡測試先行,是測試本身決定了 API 的形狀。先想「怎麼測」,往往就先逼出「關注點分離、單一職責」這些原本會被漏掉的設計問題。
- 核心手段是保留 seam:作者以 SOLID 為底,提出的準則全指向同一件事——在程式碼裡留下可以替換行為、而不必改動原類別的接縫。這是主動版本;事後在遺留碼裡硬鑿接縫,是 Seam 模型 的補救版本。
- 把建立與使用分開:含邏輯的方法不該自己
new出它的協作物件。依賴應該從外面進來——用 DI(依賴注入)在建構子注入介面,或用可覆寫的 factory method 取得,讓測試能換上受控的替代品。 - 面向介面設計:辨識應用程式裡的「角色」、抽象成介面,具體類別之間不要互相直接呼叫(純資料物件除外)。有了介面,測試才能塞進自己的 stub 或 mock(機制見 測試替身)。
- 避開難測的構造:靜態方法、singleton、全域狀態都是共享資源,無法被覆寫、無法被繼承替換;在建構子裡放邏輯也一樣,會讓實例難以在測試裡替換掉。盡量減少它們的數量。
NOTE
靜態方法為什麼難測?因為它充當「公共共享資源」——沒有實例可注入、不能被覆寫、也無法靠繼承換掉。這正是許多 TDD 實踐者不愛 singleton 的原因。若非用 singleton 不可,也要把業務邏輯與 singleton 管理邏輯拆成兩個類別(SRP),讓邏輯那半邊仍可被獨立測試。
⚖️ 建立與使用綁死 vs. 依賴注入
難測 — 建構子做真工、方法內直接 new 依賴
// 建構子就去讀設定、方法內直接 new 出 logger
// 測試無法替換任何一個依賴,只能連著真實檔案系統一起跑 → 不 FICC
class OrderProcessor {
constructor() {
this.config = readConfigFile("/etc/app.conf"); // 建構子做真工:碰檔案系統
}
process(order) {
const logger = new FileLogger("/var/log/app.log"); // 方法內 new 具體類別
if (!this.config.enabled) {
logger.log("processing disabled");
return false;
}
// ... 真正的邏輯
return true;
}
}依賴是被「直接使用」的,沒有從外面傳進來的縫。測試想控制 config 或攔截 logger,只能連真實檔案一起啟動——慢、要設定、還可能因外部原因而紅。
可測 — 建構子只接線,依賴由外注入
// 角色都抽成介面(config / logger),從建構子注入
// 建構子不做真工,只把依賴接起來 → seam 就在參數上
class OrderProcessor {
constructor(config, logger) {
this.config = config; // 注入,不自己讀檔
this.logger = logger; // 注入,不自己 new
}
process(order) {
if (!this.config.enabled) {
this.logger.log("processing disabled");
return false;
}
// ... 真正的邏輯
return true;
}
}
// 測試:塞入受控替代品,不碰檔案系統
const analyzer = new OrderProcessor(
{ enabled: false }, // stub:餵輸入
{ calls: [], log(m) { this.calls.push(m); } }, // 需要驗互動時才用 mock
);建立與使用被分開了:誰來組裝依賴是外面的事,OrderProcessor 只管用。測試因此能一句話換掉整個環境。
TIP
若語言/框架允許事後替換(例如把方法設為可覆寫、或用可替換的 delegate),也是一種留縫方式——測試時繼承覆寫、或換掉 delegate 就能斷開依賴。重點不在語法,而在「有沒有一個不改原始碼就能替換行為的入口」。
⚖️ 這值得嗎——本章提出的批評
為可測試性設計並非沒有代價,作者誠實地列出爭議面:
- 工作量:通常得寫更多程式碼。但反過來看,這些額外設計往往揭露了你本來會忽略的問題(關注點分離、SRP);而且測試碼和生產碼一樣重要,它逼你先想清楚別人會怎麼用你的 API。
- 複雜度:有時會顯得過度設計——為了測試多出一堆介面、暴露本不想公開的行為語義;介面一多,要找到某方法的真實實作也變難(好的導覽工具能緩解)。
- 暴露敏感智財:安全機制、授權、專利演算法有時不該被公開;雖有變通做法,但那本質上是在規避可測試性設計的理念,涉及安全或專利時可能只能妥協。
- 有時就是做不到:出於組織或政治因素、或程式碼太脆弱不宜重構,你未必能改設計。
IMPORTANT
更根本的一點:在 Ruby、Smalltalk 等動態語言裡,執行期什麼都能替換,根本不太需要「為可測試性設計」。有趣的是 Ruby 社群後來仍發現了設計本身的重要性——這暗示設計是獨立於可測試性的活動。所以結論是:可測試性不該是設計目標,良好的設計才是。 通過可測試性檢驗,不代表設計就是好的;而遵循 SOLID 帶來的可維護、可讀、好開發,即使可測試性不再是問題也依然成立。
🔑 Takeaways
- 難以測試通常是設計訊號:好測試要 FICC,測不動代表少了可替換的 seam。
- 核心手段是把建立與使用分開——用 DI 或可覆寫的 factory 注入依賴,面向介面設計,別在含邏輯的方法裡
new具體類別。 - 避開難測構造:少用靜態方法/singleton/全域狀態,別在建構子放邏輯;非用 singleton 不可就把邏輯與生命週期管理拆開。
- 這是主動預留接縫;事後在遺留碼補救則見 Seam 模型。替身機制見 測試替身。
- 最終目標是良好的設計本身,不是可測試性——通過可測試性檢驗 ≠ 設計就對了。
No notes yet — jot your takeaways or Q&A here.