章節概述#
本章回答 TDD 的四個基本策略問題:測試是什麼意思?何時測試?如何選擇測試的邏輯?如何選擇測試的資料?作者透過六個模式——Test、Isolated Test、Test List、Test First、Assert First、Test Data 和 Evident Data——建立了 TDD 的基本實踐框架。
Test(名詞)#
如何測試你的軟體?寫自動化測試。
「測試」既是動詞(評估)也是名詞(一個可執行的驗證程序)。沒有軟體工程師會在不測試的情況下發佈修改——除了極度自信和極度粗心的人。但「手動測試修改」和「擁有自動化測試」是截然不同的事。
作者用 Gerry Weinberg 的影響圖(influence diagram)來解釋測試不足的惡性循環:
- 壓力越大 → 測試越少
- 測試越少 → 錯誤越多
- 錯誤越多 → 壓力越大
flowchart LR
A["😰 壓力增加"] -->|"沒時間"| B["測試減少"]
B -->|"品質下降"| C["錯誤增多"]
C -->|"修 bug"| A這是一個正回饋迴路(positive feedback loop)。要打破它,需要替換其中一個元素——將「手動測試」替換為「自動化測試」。

Figure 25.1: The "no time for testing" death spiral
有了自動化測試,當壓力升高時,你可以執行測試。測試是程式設計師的點金石(Programmer’s Stone),將恐懼轉化為無聊:「不,我沒有弄壞任何東西,測試全是綠燈。」壓力越大就越頻繁執行測試,立即獲得正面回饋,減少錯誤,進而降低壓力。
注意: 如果壓力高到某個程度,這個良性循環仍然會崩潰(「沒時間跑測試了,直接發佈!」)。但有了自動化測試,至少你有選擇恐懼程度的能力。
作者還分享了一個小故事:新寫的測試要不要先執行看它失敗?通常不必,但有一次他自信滿滿地為 in-memory transaction 的 rollback 寫了測試,花了兩小時實作卻一直失敗,最後回到原點才「心血來潮」跑了一下測試——結果它直接通過了。因為 transaction 機制的本質就是變數在 commit 前不會真正被改變。所以,還是跑一下你的新測試吧。
Isolated Test#
測試之間的執行應該如何互相影響?完全不影響。
作者回憶早期經驗:一組長時間執行的 GUI 自動化測試,每天早上看到椅子上一疊列印報告。好的日子只有一頁摘要,壞的日子則是厚厚一疊。他學到了兩個教訓:
- 讓測試足夠快,自己能頻繁執行,不必等到隔天才發現問題
- 一大疊報告通常不代表一大堆問題——往往是一個測試早早失敗,讓系統處於不可預期的狀態,導致後續測試連鎖失敗
重點: 測試隔離的核心原則——如果有一個測試壞了,我只想看到一個問題;如果有兩個測試壞了,我想看到兩個問題。測試之間必須能夠完全忽略彼此。
隔離測試帶來幾個重要的延伸效果:
- 測試與執行順序無關 — 可以任意抓取一個子集來執行
- 迫使你將問題拆解為正交的維度 — 讓每個測試的環境設置簡單快速
- 鼓勵高內聚、低耦合的設計 — 作者坦言,他一直聽說這是好的設計原則,但直到開始寫隔離測試,才真正知道如何系統性地達成
Test List#
該測試什麼?在開始之前,列出所有你知道需要寫的測試。
TDD 應對程式設計壓力的第一部分是:永遠不要在不知道腳會踩到哪裡的情況下往前走。
作者曾嘗試把所有待辦事項記在腦中,結果陷入另一個正回饋迴路:
- 經驗越多 → 知道可能需要做的事越多
- 需要記住的事越多 → 能專注在當前工作的注意力越少
- 注意力越少 → 完成的事越少
- 完成的事越少 → 累積的待辦事項越多
解決方式是寫下來:
- 電腦旁放一張紙,記錄接下來幾小時要完成的事
- 牆上貼一張更大範圍(週或月)的清單
- 有新想法時,快速判斷它屬於「現在」、「之後」還是「根本不需要做」
技巧: 應用到 TDD 中——清單上放的是你想實作的測試。包含三類:(1) 每個已知操作的範例測試 (2) 尚不存在的操作的 null 版本 (3) 你預期需要的重構。
為什麼不一口氣把所有測試都寫完?兩個原因:
- 每個已實作的測試都是重構的慣性 — 如果你寫了十個測試後才發現參數順序需要對調,你就更不願意去改
- 十個紅燈測試離綠燈太遠 — 如果你渴望快速回到綠燈,就得全部丟掉;如果你想全部修好,就要長時間盯著紅燈
補充: 保守的登山者有一條規則:四肢中隨時有三肢要固定。TDD 的純粹形式——永遠只差一步就能回到綠燈——就像這個「四之三」規則。
當你讓測試通過時,實作會暗示新的測試,寫到清單上。重構也一樣。當一個 session 結束時,如果清單上還有未完成的項目:
- 功能尚未完成 → 下次繼續用同一份清單
- 發現超出範圍的重構 → 移到「之後」清單
- 測試案例 → 作者表示他不記得曾經把測試推遲到「之後」。如果他能想到一個可能不通過的測試,讓它通過比發佈程式碼更重要
Test First#
何時寫測試?在寫被測試的程式碼之前。
你不會「之後再測」。身為程式設計師,你的目標是讓功能運作。但你需要一種思考設計的方式,也需要一種控制範圍的方法。
壓力與測試的影響圖再次出現(參見前面的 Test 模式)。如果我們採用「永遠先測試」的規則,就可以反轉這個圖,得到一個良性循環:
- 先寫測試 → 減少壓力
- 壓力減少 → 更可能先寫測試
flowchart LR
A["先寫測試"] -->|"立即回饋"| B["壓力降低"]
B -->|"有餘裕"| C["更願意先寫測試"]
C --> A
style A fill:#4CAF50,color:#fff
style B fill:#4CAF50,color:#fff
style C fill:#4CAF50,color:#fff重點: 測試的即時回報——作為設計工具和範圍控制工具——使得我們在中等壓力下仍然能堅持 Test First。壓力還有很多其他來源,所以測試必須存在於其他良性循環中,才不會在壓力過大時被放棄。
Assert First#
何時寫 assert?試著先寫 assert。
這是一個美妙的自相似(self-similarity)結構:
- 從哪裡開始建造系統?從你想對完成的系統講述的故事開始
- 從哪裡開始寫功能?從你想讓完成的程式碼通過的測試開始
- 從哪裡開始寫測試?從完成時會通過的 assert 開始
flowchart RL
D["4. 開啟 Server"] --> C["3. 建立 Socket 連線"]
C --> B["2. 讀取回覆內容"]
B --> A["1. 撰寫 assert 斷言"]
style A fill:#4CAF50,color:#fff先寫 assert 有強大的簡化效果。寫測試時你同時在解決很多問題:功能放在哪?命名是什麼?怎麼驗證正確答案?正確答案是什麼?這個測試暗示了哪些其他測試?
其中「正確答案是什麼」和「如何驗證」可以最容易地從其餘問題中分離出來。
以 socket 通訊為例,從 assert 開始逐步往回推導:
// 第一步:先寫 assert
testCompleteTransaction() {
...
assertTrue(reader.isClosed());
assertEquals("abc", reply.contents());
}
// 第二步:reply 從哪來?
testCompleteTransaction() {
...
Buffer reply = reader.contents();
assertTrue(reader.isClosed());
assertEquals("abc", reply.contents());
}
// 第三步:reader 從哪來?
testCompleteTransaction() {
...
Socket reader = Socket("localhost", defaultPort());
Buffer reply = reader.contents();
assertTrue(reader.isClosed());
assertEquals("abc", reply.contents());
}
// 第四步:需要先開啟 server
testCompleteTransaction() {
Server writer = Server(defaultPort(), "abc");
Socket reader = Socket("localhost", defaultPort());
Buffer reply = reader.contents();
assertTrue(reader.isClosed());
assertEquals("abc", reply.contents());
}每一步都在幾秒鐘內得到回饋,用極小的步伐建構出完整的測試。
Test Data#
測試使用什麼資料?使用讓測試容易閱讀和理解的資料。
你是為讀者寫測試,不只是為電腦。不要隨意散佈資料值——如果資料有差異,那個差異必須有意義;如果概念上 1 和 2 沒有差別,就用 1。
幾個實用原則:
- 不要用十個元素的 list,如果三個元素就能引導出相同的設計和實作決策
- 避免用同一個常數表達不同的意思 — 例如測試
plus()時,用2 + 3比2 + 2好,因為後者無法揪出引數順序錯誤的問題
補充: Test Data 的替代方案是 Realistic Data(使用真實世界的資料)。適用場景包括:(1) 用實際執行的事件追蹤測試即時系統 (2) 平行測試時比對新舊系統的輸出 (3) 重構模擬程式時需要精確比對結果(尤其涉及浮點數精度)。
Evident Data#
如何呈現資料的意圖?在測試中同時包含預期結果和實際結果,並讓它們之間的關係一目了然。
你是為未來的讀者寫測試——可能幾十年後,某個人(很可能是你自己)會問:「這傢伙當時在想什麼?」你要留下盡可能多的線索。
以貨幣轉換為例(USD 轉 GBP 匯率 2:1,手續費 1.5%,兌換 $100)。比較兩種寫法:
隱晦的版本(使用常數):
Bank bank = new Bank();
bank.addRate("USD", "GBP", STANDARD_RATE);
bank.commission(STANDARD_COMMISSION);
Money result = bank.convert(new Note(100, "USD"), "GBP");
assertEquals(new Note(49.25, "GBP"), result);顯而易見的版本:
Bank bank = new Bank();
bank.addRate("USD", "GBP", 2);
bank.commission(0.015);
Money result = bank.convert(new Note(100, "USD"), "GBP");
assertEquals(new Note(100 / 2 * (1 - 0.015), "GBP"), result);第二種寫法讓你一眼就能看出輸入數字和預期結果之間的數學關係。
技巧: Evident Data 的附帶好處是讓程式設計變簡單——寫完 assert 中的表達式後,你就知道需要實作什麼運算。你甚至可以用 Fake It 來逐步發現這些運算該放在哪裡。
Evident Data 看似違反了「不要在程式碼中使用 magic number」的規則。但在單一方法的範圍內,數字之間的關係是顯而易見的。如果已經有定義好的符號常數,當然使用符號形式更好。
本章小結#
本章建立了 TDD 的策略基礎,六個模式的核心要點:
| 模式 | 核心要點 |
|---|---|
| Test | 用自動化測試取代手動測試,打破壓力的惡性循環 |
| Isolated Test | 測試之間完全獨立,推動高內聚低耦合的設計 |
| Test List | 開始前列出測試清單,保持專注並控制範圍 |
| Test First | 在寫程式碼之前寫測試,將測試作為設計和範圍控制工具 |
| Assert First | 從 assert 開始寫測試,逐步往回推導出完整的測試 |
| Test Data | 使用易讀、有意義的資料,避免不必要的複雜性 |
| Evident Data | 讓測試中的資料關係一目了然,為未來的讀者留下線索 |