章節概述#

本章是 TDD 旅程的起點。作者以多幣別報表為背景需求,示範如何從一個測試清單開始,選擇最簡單的項目下手,然後以極小的步伐讓測試從紅燈變成綠燈,最後透過消除重複來完成一輪完整的 TDD 循環。

flowchart LR
    A["1. 新增測試"] --> B["2. 執行<br/>看到失敗 🔴"]
    B --> C["3. 做小修改"]
    C --> D["4. 執行<br/>全部通過 🟢"]
    D --> E["5. 重構<br/>消除重複"]
    E --> A

從需求到測試清單#

假設我們有一份投資報表,需要支援多幣別:

  • 能夠將兩種不同幣別的金額相加,並根據匯率轉換結果
  • 能夠將一個金額(股價)乘以一個數字(股數)得到總金額

作者列出了一個待辦測試清單(to-do list),用來追蹤進度:

  • $5 + 10 CHF = $10(匯率 2:1 時)
  • $5 * 2 = $10

技巧: 維護一個測試清單,隨時記錄需要撰寫的測試。完成的劃掉,想到新的就加上去。這能幫助你保持專注,也能清楚知道何時算「完成」。

從測試開始,而非從物件開始#

我們需要什麼物件?這是個陷阱問題——我們不從物件開始,而是從測試開始

撰寫測試時,我們想像操作的理想介面(perfect interface)。從最佳的 API 出發、再反推實作,比一開始就寫出複雜醜陋的「務實」程式碼更好。

乘法的測試如下:

public void testMultiplication() {
    Dollar five = new Dollar(5);
    five.times(2);
    assertEquals(10, five.amount);
}

補充: 這裡有 public field、side effect、用整數表示金額等問題。作者刻意先忽略這些,記錄到待辦清單中,優先讓測試通過。小步前進是核心策略。

讓測試編譯通過#

這個測試一開始連編譯都過不了,有四個編譯錯誤。作者逐一解決,每次消滅一個錯誤:

  1. 沒有 Dollar 類別 — 建立空類別
  2. 沒有建構子 — 加入空建構子
  3. 沒有 times(int) 方法 — 加入 stub 方法
  4. 沒有 amount 欄位 — 宣告 field
class Dollar {
    int amount;

    Dollar(int amount) {
    }

    void times(int multiplier) {
    }
}

現在測試可以編譯並執行了——然後我們看到它失敗了。

Figure 1.1: Progress! The test fails

重點: 失敗也是進步!我們從模糊的「給我多幣別功能」,轉變為具體的「讓這個測試通過」。問題範圍大幅縮小,恐懼感也隨之降低。

用最小變更讓測試通過#

最小的修改方式——直接將 amount 硬編碼為 10:

int amount = 10;

Figure 1.2: The test runs

測試通過了,綠燈亮起。但循環尚未結束——接下來是最關鍵的第五步:消除重複

消除重複:從常數到變數#

那個 10 其實是 5 * 2——測試中的資料和程式碼中的資料重複了。作者透過一系列小步驟逐漸消除這個重複:

第一步:顯式寫出乘法

int amount = 5 * 2;

第二步:將設值移到 times() 方法

int amount;

void times(int multiplier) {
    amount = 5 * 2;
}

第三步:讓建構子保存 amount

Dollar(int amount) {
    this.amount = amount;
}

第四步:在 times() 中使用 amount 取代常數 5

void times(int multiplier) {
    amount = amount * 2;
}

第五步:用參數 multiplier 取代常數 2

void times(int multiplier) {
    amount = amount * multiplier;
}

最終版本:使用 *= 運算子

void times(int multiplier) {
    amount *= multiplier;
}

重點:Dependency 與 Duplication 的關係

Steve Freeman 指出,問題的本質不是重複,而是依賴(dependency)——你無法在不改變程式碼的情況下撰寫新測試。依賴是軟體開發在各個層級上的核心問題,而重複是依賴的症狀。在 TDD 中,透過消除重複來消除依賴,最大化下一個測試只需一處修改就能通過的機會。

關於步伐大小#

技巧: TDD 的重點不是必須走極小步,而是有能力走極小步。平常可以走大步,但當事情變得詭異時,你會慶幸自己能切換到小步模式。如果你能走得太小,就一定能走到恰當的大小;反過來,如果你只走大步,就永遠不知道小步是否更合適。

本章小結#

本章完成了第一輪完整的 TDD 循環,要點回顧:

  • 列出測試清單,記錄已知需要的測試
  • 用程式碼片段說故事,描述我們期望的操作介面
  • 暫時忽略 JUnit 細節
  • 用 stub 讓測試編譯通過
  • 犯下醜陋的罪讓測試執行通過(硬編碼 amount = 10
  • 逐步泛化程式碼,用變數取代常數
  • 隨時將新發現的問題加入待辦清單,而非一次全部處理