概述:從複式簿記到 TDD#
Martin 以複式簿記(double-entry bookkeeping)的歷史作為開場,建立 TDD 的類比基礎。複式簿記由古羅馬 Pliny the Elder 發明,經開羅銀行家、威尼斯商人改良,1494 年由 Luca Pacioli 正式記錄出版。其核心概念是:每筆交易都有兩面,所有借貸相加必須為零——這是平衡的數字。
Goethe 在《乘徒歷程》中讚嘆複式簿記:
“What a thing it is to see the order which prevails throughout his business! By means of this he can at any time survey the general whole, without needing to perplex himself in the details.”
軟體與會計的相似性#
- 兩者都是「魔法師的技藝」,外人難以理解其深度
- 兩者的產出都是複雜的文件(會計帳本 vs. 原始碼),由符號構成
- 任何一個符號的錯誤都可能導致嚴重後果
- 兩者都需要大量訓練和經驗,都關注精確的細節管理
TDD 就是軟體開發的複式簿記。所有事情都被表述兩次——以互補的形式存在於測試與產品程式碼中,透過保持測試通過來維持平衡。
TDD 的本質#
TDD 的核心紀律要求達成四個目標:
| # | 目標 | 說明 |
|---|---|---|
| 1 | 可信賴的測試套件 | 通過即意味著可以部署 |
| 2 | 足夠解耦的產品程式碼 | 使其可測試且可重構 |
| 3 | 極短週期的回饋迴圈 | 維持穩定的開發節奏 |
| 4 | 測試與產品程式碼充分解耦 | 方便各自維護 |
TDD 三法則#
第一法則#
在撰寫因缺少產品程式碼而失敗的測試之前,不要撰寫任何產品程式碼。
先寫測試看似違反直覺,但如果你能寫出產品程式碼,你同樣也能寫出測試它的程式碼——只是順序不同而已。
第二法則#
測試只需寫到剛好足以失敗(含編譯失敗)。透過撰寫產品程式碼來解決失敗。
通常測試的第一行就會編譯失敗(因為引用了尚不存在的程式碼),這意味著你幾乎寫不到一行測試就需要切換去寫產品程式碼。
第三法則#
產品程式碼只寫到剛好讓當前失敗的測試通過。測試通過後,回去寫更多測試。
三法則將你鎖定在一個只有幾秒鐘長的循環中:寫一行測試 → 編譯失敗 → 寫產品程式碼 → 編譯通過 → 寫更多測試 → 斷言失敗 → 寫更多產品程式碼 → 斷言通過…
flowchart LR
A["寫一行測試"] --> B["編譯失敗"]
B --> C["寫產品程式碼"]
C --> D["編譯通過"]
D --> E["寫更多測試"]
E --> F["斷言失敗"]
F --> G["寫更多產品程式碼"]
G --> H["斷言通過"]
H --> A
style B fill:#d62828,color:#fff
style F fill:#d62828,color:#fff
style D fill:#2d6a4f,color:#fff
style H fill:#2d6a4f,color:#fff遵循三法則的好處#
告別除錯(Losing the Debug-foo)#
想像一間房間裡所有開發者都遵循三法則——無論你挑選誰、在什麼時候,他們正在開發的一切都在一分鐘左右前通過了所有測試。
- 如果一切在一分鐘前還正常,你需要除錯多少?幾乎不需要
- 你不應該精通除錯器——精通意味著你花太多時間在除錯
- 雖然 TDD 不能完全消除除錯需求,但除錯的頻率和持續時間會大幅下降
文件化(Documentation)#
遵循三法則時,你正在為整個系統撰寫程式碼範例:
- 想知道如何建立某個商業物件?有測試展示所有建立方式
- 想知道如何呼叫某個 API?有測試示範包括所有錯誤條件
- 這些測試是最底層的完美文件——用你熟悉的語言撰寫、完全無歧義、可執行、且不會與系統脫節
測試不擅長描述系統的高層動機,但在最底層,它們是最好的文件。每個測試獨立存在、聚焦於系統的一小部分,沒有測試之間的依賴糾纏。
補上設計的漏洞(Holes in the Design)#
事後寫測試的問題:
- 你已經知道系統能運作(手動測試過了),寫測試變成無聊的義務
- 遇到難以測試的程式碼時,你會逃避——因為改設計太麻煩、可能破壞其他東西
- 你走開了,測試套件留下了漏洞——而你知道團隊中每個人都在做同樣的事
判斷測試套件有多少漏洞的方法:觀察測試通過時程式設計師笑得多大聲。笑得越多,漏洞越多。好的測試套件通過時,你能做出的決定是:部署。
樂趣(Fun)#
TDD 不是贏彩券的那種快樂,但每次繞一圈 TDD 迴圈,都會有一小劑內啡肽釋放——每個如預期失敗的測試讓你點頭微笑,每次讓測試通過都讓你感到一點掌控感。
設計(Design)#
- 先寫測試迫使你把程式碼設計成容易測試的——這是無法逃避的
- 什麼讓程式碼難以測試?耦合與依賴
- 容易測試的程式碼就是解耦的程式碼
恐懼與勇氣(Fear & Courage)#
程式設計師害怕改變——每次修改都有破壞的風險。看到糟糕的程式碼,腦中閃過「我不碰它!」——因為碰壞了就永遠是你的責任。
這種恐懼導致程式碼腐爛:沒人清理、沒人改善,被迫修改時選擇對程式設計師最安全而非對系統最好的方式。
但如果你有一套可信賴的測試套件,在幾秒內就能執行完畢——你還會害怕清理程式碼嗎?測試會在你破壞什麼的瞬間告訴你。有了測試套件,你就能安全地清理程式碼。
flowchart TD
subgraph 無TDD["無 TDD:恐懼的惡性循環"]
direction TB
A1["害怕改變"] --> A2["不敢清理程式碼"]
A2 --> A3["程式碼持續腐爛"]
A3 --> A4["更多技術債累積"]
A4 --> A1
end
subgraph 有TDD["有 TDD:信心的良性循環"]
direction TB
B1["可信賴的測試套件"] --> B2["消除恐懼"]
B2 --> B3["安全地清理程式碼"]
B3 --> B4["程式碼持續改善"]
B4 --> B1
end
style 無TDD fill:#fff5f5,stroke:#d62828
style 有TDD fill:#f0fff0,stroke:#2d6a4f
style A1 fill:#d62828,color:#fff
style A3 fill:#d62828,color:#fff
style B1 fill:#2d6a4f,color:#fff
style B4 fill:#2d6a4f,color:#fff童子軍法則(The Boy Scout Rule)#
簽入時讓程式碼比簽出時更乾淨。
想像每次簽入都讓程式碼更乾淨一點——系統會隨時間越來越好。這就是我們實踐 TDD 的原因:為了能驕傲地看著程式碼,知道每次觸碰它都讓它更好。
第四法則:重構#
從三法則中,TDD 的循環是寫失敗的測試 → 寫通過的產品程式碼。但如果只有紅燈和綠燈交替,程式碼會快速退化——因為人類不擅長同時做兩件事。專注於行為時,無法同時兼顧結構。
遵循 Kent Beck 的建議:
先讓它能動。再讓它正確。
因此,TDD 循環加入第三個顏色:紅 → 綠 → 重構。

Figure 2.1: Red → green → refactor
關於重構的幾個澄清:
| 澄清 | 說明 |
|---|---|
| 重構是持續性的活動 | 每次繞 TDD 迴圈都要清理 |
| 重構不改變行為 | 只在測試通過時重構,且重構期間測試持續通過 |
| 重構不會出現在排程或計畫中 | 不需要預留時間或請求許可,就像上完廁所洗手一樣自然 |
基礎範例#
TDD 的節奏在文字中很難完全傳達。每個範例都有對應的線上影片,建議先看影片再閱讀文字說明。
範例一:Stack(整數堆疊)#
這個範例從零開始建立一個整數堆疊,展示 TDD 的基本節奏。全程約 14 分 24 秒。
關鍵規則與啟示:
| 規則 | 內容 |
|---|---|
| Rule 1 | Write the test that forces you to write the code you already know you want to write.(寫迫使你撰寫你已經知道想寫的程式碼的測試) |
| Rule 2 | Make it fail. Make it pass. Clean it up.(讓它失敗。讓它通過。清理乾淨。) |
| Rule 3 | Don’t go for the gold.(不要急著挑戰核心功能) |
進展過程摘要:
| # | 測試 | 實作 | 時間 |
|---|---|---|---|
| 1 | 空測試確認環境正常 | — | — |
| 2 | 能否建立 Stack 物件 | 建立空類別 | 00:44 - 00:54 |
| 3 | isEmpty() | 先回傳 false(看它失敗)再回傳 true(看它通過),9 秒驗證測試的兩面 | 01:24 - 01:58 |
| 4 | push 後不為空 | 用 boolean flag 追蹤 | 02:24 - 03:46 |
| 5 | (重構) | 提取重複的 stack 建立為類別欄位 | 04:24 |
| 6 | push + pop 後為空 | 加入 pop 方法 | 05:17 - 06:06 |
| 7 | 兩次 push 後 size 為 2 | 引入 size 計數器 | 06:48 - 09:28 |
| 8 | 空堆疊 pop 拋出 Underflow 例外 | 加入例外處理 | 10:27 - 11:18 |
| 9 | push 值能被 pop 回來 | 先回傳常數,再用欄位記錄 | 11:49 - 12:50 |
| 10 | FILO 行為(先進後出) | 引入陣列 | 13:36 - 14:24 |

Figure 2.2: Rearranged screen
Rule 3 的意義:新手常被誘惑先測試最有趣的核心功能(如堆疊的 FILO 行為)。但先處理外圍的輔助細節(如空、大小、例外),不僅能建立穩固的基礎,還常常在過程中發現簡化核心功能的機會。
範例二:Prime Factors(質因數分解)#
這個範例有一個故事:大約 2002 年,Martin 的兒子 Justin 帶回質因數分解的作業。Martin 原本打算用埃拉托斯特尼篩法產生質數列表,但決定「先寫測試看看會發生什麼」。
關鍵規則與啟示:
- Rule 4: Write the simplest, most specific, most degenerate test that will fail.(寫最簡單、最具體、最退化的會失敗的測試)
- Rule 5: Generalize where possible.(盡可能一般化)
進展過程摘要:
| # | 測試輸入 | 實作變更 |
|---|---|---|
| 1 | factorsOf(1) | 回傳空列表 |
| 2 | factorsOf(2) | 加入 if (n>1) factors.add(2) |
| 3 | factorsOf(3) | 只改一個字元:factors.add(2) → factors.add(n) —— 用變數取代常數的一般化 |
| 4 | factorsOf(4) | 需要除以 2 的邏輯,引入 if (n%2==0) 和 n/=2 |
| 5 | factorsOf(5,6,7) | 無需修改就通過!表示方向正確 |
| 6 | factorsOf(8) | 將 if 改為 while —— while 是 if 的一般化形式 |
| 7 | factorsOf(9) | 需要除以 3,但直接加另一個 while 迴圈是嚴重的重複和違反 Rule 5 |
| 8 | 最終一般化 | 引入 divisor 變數和外層迴圈 |
最終結果:三行核心程式碼
for (int divisor = 2; n > 1; divisor++)
for (; n % divisor == 0; n /= divisor)
factors.add(divisor);「隨著測試越來越具體,程式碼越來越一般化」(As the tests get more specific, the code gets more generic)。這是 TDD 的核心箴言,對測試設計和防止脆弱測試至關重要。
Martin 坐在廚房桌前驚嘆:他沒有預先計劃這個演算法,它一個測試案例一個測試案例地自行浮現。更神奇的是,他必須研究這個演算法才能理解它為什麼有效——它其實就是埃拉托斯特尼篩法的一種不同形式。
這引出一個深刻的可能性:TDD 或許是一種漸進式推導演算法的通用技術。
範例三:Bowling Game(保齡球計分)#
1999 年,Martin 與 Bob Koss 在 C++ 研討會上選擇了保齡球計分問題來練習 TDD。

Figure 2.3: The infamous gutter ball
保齡球計分規則:
| 情況 | 計分方式 |
|---|---|
| 一般 | 一局有 10 個 frame,每個 frame 有兩次投球機會,分數 = 該 frame 兩球擊倒的瓶數 |
| Strike(全倒) | 分數 = 10 + 接下來兩球 |
| Spare(補中) | 分數 = 10 + 接下來一球 |

Figure 2.4: A bowling score sheet
物件導向直覺的陷阱:
你可能會設計出這樣的 UML 模型:Game 有十個 Frame,每個 Frame 有 1-2 個 Roll,TenthFrame 繼承 Frame 並允許 2-3 個 Roll。這看起來合理——甚至可以分配給四個人分工開發。

Figure 2.5: A UML diagram of the scoring of bowling
TDD 的進展:
| # | 測試案例 | 結果 |
|---|---|---|
| 1 | Gutter game(全部洗溝):20 球全 0,分數 0 | score() 回傳 0 |
| 2 | All ones(全部 1):20 球全 1,分數 20 | 在 roll() 中累加分數 |
| 3 | (重構) | 提取 rollMany(n, pins) 消除測試中的重複 |
| 4 | One spare 測試 | 問題來了! |
關鍵設計轉折——錯置的職責(Misplaced Responsibility):
- Rule 6: When the code feels wrong, fix the design before proceeding.(當程式碼感覺不對時,先修正設計再繼續)
- 問題:
score()函數宣稱計算分數,但實際上分數是在roll()中計算的 - 在
roll()中處理 spare 會非常醜陋(需要記住上一球、檢查奇偶數等) - 解決方案:
roll()只負責記錄所有投球到陣列,score()負責遍歷陣列計算分數
錯置的職責(Misplaced Responsibility)是常見的設計缺陷——宣稱執行某計算的函數實際上沒有執行該計算,計算在別處完成。這通常源自程式設計師自以為聰明的「優化」。
第二個設計修正:
- 用 21 個元素的陣列逐球遍歷時,spare 的偵測需要檢查索引的奇偶——又是個壞味道
- 改為以 frame 為單位遍歷(10 次迴圈),引入關鍵數字 10
- 之後 spare 和 strike 的處理都變得自然
flowchart TD
A["原始設計"] --> A1["roll() 累加分數"]
A --> A2["score() 只回傳累加值"]
A1 --> B{"發現錯置職責"}
A2 --> B
B --> C["修正設計"]
C --> C1["roll() 只記錄投球"]
C --> C2["score() 負責計算分數"]
C1 --> D{"以 21 元素陣列遍歷\n需檢查奇偶索引"}
C2 --> D
D --> E["進一步改進"]
E --> E1["以 frame 為單位遍歷\n10 次迴圈"]
E1 --> F["Spare 與 Strike\n處理自然浮現"]
F --> G["最終:1 個 for 迴圈 + 2 個 if\n僅 14 行程式碼"]
style A fill:#d62828,color:#fff
style C fill:#e9c46a,color:#000
style E fill:#2d6a4f,color:#fff
style G fill:#2d6a4f,color:#fff最終結果:
for (int frame = 0; frame < 10; frame++) {
if (isStrike(frameIndex)) {
score += 10 + strikeBonus(frameIndex);
frameIndex++;
} else if (isSpare(frameIndex)) {
score += 10 + spareBonus(frameIndex);
frameIndex += 2;
} else {
score += twoBallsInFrame(frameIndex);
frameIndex += 2;
}
}驚人的結局:
- 測試 perfect game(完美比賽,300 分,12 球全 strike)—— 預期它會失敗,結果直接通過!
- 程式碼讀起來就像計分規則本身
- 第十個 frame 不是特例——它在計分規則上與其他 frame 完全相同,只是記分卡畫法不同
- 原本的 UML 設計會產生約 400 行程式碼和四個類別;TDD 導出的解決方案只有 14 行,一個 for 迴圈加兩個 if
有人抱怨說照 UML 圖來開發會更容易維護。Martin 的回應:「你寧願維護四個類別中的 400 行程式碼,還是一個 for 迴圈加兩個 if 的 14 行程式碼?」
TDD 的六條規則總整理#
| 規則 | 內容 |
|---|---|
| Rule 1 | 寫迫使你撰寫你已知想寫的程式碼的測試 |
| Rule 2 | 讓它失敗、讓它通過、清理乾淨 |
| Rule 3 | 不要急著挑戰核心功能(Don’t go for the gold) |
| Rule 4 | 寫最簡單、最具體、最退化的會失敗的測試 |
| Rule 5 | 盡可能一般化(Generalize where possible) |
| Rule 6 | 當程式碼感覺不對時,先修正設計再繼續 |
結語#
本章涵蓋了 TDD 的動機與基礎。TDD 的三法則加上重構法則,構成了軟體開發的「複式簿記」。透過三個範例——Stack、Prime Factors、Bowling Game——展示了 TDD 的節奏和威力:測試驅動的設計不僅更簡潔,還能產生出人意料的優雅解決方案。下一章將深入探討 TDD 的進階主題。