概述#
TDD 並非淺層技能——遵循三條法則只是起點。TDD 是一項深層技能,包含多層面向,需要數月甚至數年才能精通。本章探討測試設計中的幾個關鍵層面:從資料庫與 GUI 的測試策略、測試設計原則、測試模式,到深具啟發性的轉換優先前提(Transformation Priority Premise)。
測試資料庫(Testing Databases)#
兩條核心規則#
- 不要測試資料庫本身——你可以假設資料庫是正常運作的
- 將資料庫與業務規則解耦——測試的重點是查詢(queries)是否正確構建
Gateway 模式#
作者提出使用 Gateway 介面來隔離資料庫與業務邏輯:
- 在 Gateway 介面中,為每種查詢建立對應方法(例如
getEmployeesHiredAfter(2001)) - GatewayImpl 負責實作實際的 SQL、ORM 操作或 NoSQL 存取
- SQL、ORM 框架、資料庫 API 都不應被架構邊界之上的程式碼所知曉
- GatewayImpl 應將資料庫取出的資料解包,建構為適當的**業務物件(business objects)**傳遞給業務規則

Figure 4.1: Testing the database
測試策略#
- 建立一個足夠簡單的測試資料庫,驗證每個查詢函式的效果
- 不要使用生產資料庫進行測試;測試前先還原備份,確保每次都對相同資料執行
- 測試業務規則時,使用 stubs 和 spies 取代 GatewayImpl,不要連接真實資料庫(太慢且容易出錯)
測試 GUI(Testing GUIs)#
三條規則#
- 不要測試 GUI
- 測試 GUI 以外的所有東西
- GUI 比你想像的更小
Presenter 與 View Model#
GUI 只是系統中負責將資訊呈現到螢幕上的極小部分——它只是建構發送給繪圖引擎的命令。作者建議盡可能縮小 GUI 的範圍:
- 引入 Presenter 模組負責格式化與排列資料
- Presenter 決定按鈕狀態、選單名稱、數字格式、顏色與字型
- Presenter 產生一個稱為 View Model 的簡單資料結構(包含字串和旗標)
- GUI 只需根據 View Model 建構螢幕命令

Figure 4.2: The interactor tells the presenter what data to display
Interactor 可透過 Presenter 的 spy 來測試;Presenter 可透過傳送命令並檢查 View Model 來測試。唯一不易自動化測試的是 GUI 本身——因此我們讓它盡可能小。
GUI 自動化測試工具#
作者不建議使用 GUI 自動化測試工具,原因是:
- 速度慢且脆弱
- GUI 是系統中最易變的模組,測試很快就會失效
- 手動用眼睛檢查即可——只需傳入預設的 View Model 即可視覺驗證
GUI Input#
GUI 輸入的測試策略同樣是將 GUI 最小化:
- GUI 框架透過 EventHandler 介面與 Controller 溝通
- Controller 將事件彙整為純資料結構 RequestModel
- Controller 透過 InputBoundary 介面將 RequestModel 傳遞給 Interactor

Figure 4.3: Testing the GUI
測試 Interactor 只需建立適當的 RequestModel;測試 Controller 只需呼叫事件並驗證產生的 RequestModel。
測試模式(Test Patterns)#
Test-Specific Subclass(測試專用子類別)#
此模式主要用作安全機制。例如,測試 X 光機的 align 方法時,不希望每次都真的開啟 X 光:
- 建立
SafeXRay子類別,覆寫turnOn方法使其什麼都不做 - 測試使用
SafeXRay實例來呼叫align

Figure 4.4: Test-specific subclass of the view model
測試專用子類別也可同時作為 spy,記錄不安全方法是否被呼叫。除了安全性考量外,此模式也適用於跳過啟動新行程或昂貴計算等場景。為了可測試性而提取方法到獨立函式,這正是測試影響程式碼設計的方式之一。
Self-Shunt(自我分流)#
Test-Specific Subclass 的變體——讓測試類別本身成為測試專用子類別:
- 測試類別直接覆寫被測試類別的方法,同時充當 spy
- 適用於需要簡單 spy 或安全機制的場景

Figure 4.5: Testing with a subclass
使用 Self-Shunt 時需注意不同測試框架的實例化行為。例如 JUnit 為每個測試方法建立新實例,而 NUnit 則在單一實例上執行所有測試方法。必須確保 spy 變數被正確重設。
Humble Object(謙卑物件)#
跨越硬體邊界的程式碼極難測試(螢幕顯示、網路介面、I/O 埠等)。Humble Object 模式是一種妥協方案:
- 承認有些程式碼無法實際被測試
- 目標是讓那些程式碼簡單到不需要測試
- 將跨邊界通訊的程式碼分為兩部分:Presenter 和 Humble Object(HumbleView)
- 兩者之間透過 Presentation 資料結構溝通

Figure 4.6: The general strategy
運作方式#
- 應用程式將資料送給 Presenter
- Presenter 將資料解包為最簡單的形式,載入 Presentation 資料結構(字串、旗標等)
- HumbleView 僅負責將 Presentation 中的資料運送過邊界
- 目標:讓 HumbleView 簡單到不值得測試
自駕車方向盤範例#
作者以自駕車的方向盤控制為例展示此模式:
- AI 發出高階命令:
turn(RIGHT, 30, 2300)—— 在 2300 毫秒內向右轉 30 度 - SteeringPresenter 將高階命令轉換為
SteeringPresentationElement陣列(含步數、方向、步進時間、延遲) - 底層控制器僅需遍歷陣列,發出步進馬達指令
- SteeringPresenter 易於測試:傳送轉向命令,檢查產生的陣列即可
將 ViewInterface、Presenter 和 Presentation 視為一個元件,HumbleView 依賴此元件。這是一種架構策略,防止高階 Presenter 依賴低階 HumbleView 的實作細節。
測試設計(Test Design)#
脆弱測試問題(The Fragile Test Problem)#
許多 TDD 新手遭遇的困境:生產程式碼的小幅變更導致大量測試失敗。變更越小、壞掉的測試越多,問題越令人沮喪。許多程式設計師在頭幾個月就因此放棄 TDD。
脆弱性永遠是設計問題。修改一個小模組卻迫使大量其他模組跟著改,正是糟糕設計的定義。測試需要像系統中其他部分一樣被妥善設計。
一對一對應的陷阱(The One-to-One Correspondence)#
一個常見且特別有害的做法:為每個生產程式碼模組 X 建立對應的測試模組 XTest。
- 這會在生產程式碼與測試套件之間建立強大的結構耦合(structural coupling)
- 每當改變生產程式碼的模組結構,測試的模組結構也被迫跟著改

Figure 4.7: Structural coupling
例如,βTest 不僅耦合於 β,還可能因 β 依賴 γ 和 δ 而間接耦合到它們——因為 β 可能需要用 γ 和 δ 來建構,或其方法接受它們作為參數。結果是對 δ 的微小變更可能影響 βTest、γTest 和 δTest。
Rule 12: 將測試的結構與生產程式碼的結構解耦。
打破對應關係(Breaking the Correspondence)#
透過**建立介面層(interface layers)**來打破一對一對應:
- 將測試模組視為獨立且彼此解耦的模組
- 測試必須練習(exercise)生產程式碼,但練習程式碼不意味著強耦合

Figure 4.8: Interface layers
αTest耦合於α,但α背後的模組族群(α1~α5)對αTest是透明的α模組是該族群的介面,好的程式設計師確保族群的細節不會洩漏出介面

Figure 4.9: Interposing a polymorphic interface
可在測試與模組族群之間插入多型介面(polymorphic interface),切斷任何傳遞性依賴。若
α5執行了α的重要功能,該功能必定能透過α介面來測試——這不是武斷的規則,而是數學上的必然。
Video Store 範例#
作者以經典的 Video Store 問題完整展示如何透過 TDD 演進程式碼,同時保持測試與生產程式碼的解耦。
演進過程摘要#
| # | 步驟 | 說明 |
|---|---|---|
| 1 | 起始測試 | 從 CustomerTest 開始,測試 Regular Movie 的租金和點數計算 |
| 2 | 減少重複 | 引入 assertFeeAndPoints 輔助方法 |
| 3 | 修正型別 | 將金額從 double 改為 int(以分為單位),避免浮點數陷阱 |
| 4 | 解耦 | 引入 VideoRegistry 解耦影片類型與標題的耦合 |
| 5 | 提取類別 | 支援多部影片後,提取 Rental 類別 |
| 6 | 多型取代條件 | 透過多型消除 if 語句:建立抽象 Movie 類別,以及 RegularMovie、ChildrensMovie 子類別 |
| 7 | 重新命名 | Customer → RentalCalculator——它才是保護測試免受底層類別變化影響的 facade |

Figure 4.10: The result diagram
關鍵觀察#
RentalCalculatorTest不知道Rental、Movie、RegularMovie、ChildrensMovie的存在(除了需要初始化的VideoRegistry)- 沒有其他測試模組直接測試這些類別——它們都是被間接測試
- 一對一對應被打破了
- 在大型系統中,此模式會反覆出現:多個模組族群各自被自己的 facade 或介面保護
特定性 vs. 通用性(Specificity vs. Generality)#
Rule 13: 測試越具體,程式碼越通用。
測試與生產程式碼向相反方向演進:
- 每新增一個測試案例,測試套件變得更具體
- 程式設計師應驅動被測試的模組族群變得更通用

Figure 4.11: General sorting algorithm
這種分歧演進意味著兩者的形態會截然不同:
- 測試成長為一系列線性的約束與規格
- 生產程式碼成長為一個豐富的邏輯與行為族群,圍繞著驅動應用程式的底層抽象組織
這種分歧風格進一步解耦了測試與生產程式碼,保護兩者免受彼此變更的影響。耦合永遠無法完全消除,目標是最小化。
轉換優先前提(Transformation Priority Premise)#
轉換(Transformations)是什麼?#
在 TDD 的 Red/Green/Refactor 迴圈中:
| 步驟 | 性質 | 說明 |
|---|---|---|
| Red | 附加性的(additive) | 新增測試程式碼 |
| Green | 轉換性的(transformative) | 改變生產程式碼的行為使其更通用 |
| Blue(Refactor) | 修復性的(restorative) | 清理程式碼但保持行為不變 |
轉換是對現有程式碼的小幅修改,改變行為的同時將解決方案通用化。
轉換清單#
作者列出以下轉換,每一種都將程式碼從極度具體的狀態推向稍微更通用的狀態:
| 轉換 | 說明 | 範例 |
|---|---|---|
| {} → Nil | 從無到有,函式回傳 null | TDD 起始的最退化測試 |
| Nil → Constant | null 轉換為常數值 | return null → return new ArrayList<>() |
| Constant → Variable | 常數改為變數 | return 2 → return n |
| Unconditional → Selection | 加入 if 條件判斷 | 注意不要讓條件太具體 |
| Value → List | 單一值變為清單/容器 | element → elements[] |
| Selection → Iteration | if 轉為 while 迴圈 | 迭代是選擇的通用形式 |
| Statement → Recursion | 單一語句變為遞迴呼叫 | 常見於支援遞迴的語言 |
| Value → Mutated Value | 變數值被修改(累加等) | 迴圈中的部分值累積 |
Fibonacci 範例#
作者以 Fibonacci 數列展示轉換的實際應用:
| # | 轉換 | 實作 |
|---|---|---|
| 1 | {} → Nil | 回傳 null |
| 2 | Nil → Constant | 回傳 BigInteger.ONE |
| 3 | Unconditional → Selection | 加入 if (n > 1) 分支 |
| 4 | Statement → Recursion | fib(n-1).add(fib(n-2)) —— 優雅但效率極差 |
直接遞迴的 Fibonacci 實作(
fib(n-1) + fib(n-2))導致指數級時間複雜度。fib(40)在作者的電腦上花了 9 秒。改用 Selection → Iteration(尾遞迴或迴圈形式)後,fib(100)僅需 10 毫秒。
flowchart TD
Start["fib(0)=0, fib(1)=1, fib(2)=1"]
Start --> Fork{"分岔點"}
Fork -->|"Statement → Recursion"| PathA["遞迴:fib(n-1) + fib(n-2)"]
Fork -->|"Selection → Iteration"| PathB["迭代:迴圈累加"]
PathA --> ResultA["fib(40) = 9 秒"]
PathB --> ResultB["fib(100) = 10ms"]
ResultA --> ComplexA["指數級複雜度"]
ResultB --> ComplexB["線性複雜度"]
style Fork fill:#f9f,stroke:#333,stroke-width:2px
style ComplexA fill:#fbb,stroke:#333
style ComplexB fill:#bfb,stroke:#333Rule 14: 若某個轉換導致次優解,嘗試不同的轉換。
轉換優先順序#
作者相信存在一個大致的優先順序(但僅是前提,非數學證明):
| 優先順序 | 轉換 |
|---|---|
| 1 | {} → Nil |
| 2 | Nil → Constant |
| 3 | Constant → Variable |
| 4 | Unconditional → Selection |
| 5 | Value → List |
| 6 | Selection → Iteration |
| 7 | Statement → Recursion |
| 8 | Value → Mutated Value |
flowchart TD
T1["{} → Nil"] --> T2["Nil → Constant"]
T2 --> T3["Constant → Variable"]
T3 --> T4["Unconditional → Selection"]
T4 --> T5["Value → List"]
T5 --> T6["Selection → Iteration"]
T6 --> T7["Statement → Recursion"]
T7 --> T8["Value → Mutated Value"]
T1 -.-|"更通用"| T8
T8 -.->|"Rule 14:次優解時退回"| T7
T7 -.->|"嘗試不同轉換"| T6
style T1 fill:#e6f3ff,stroke:#333
style T8 fill:#ffe6e6,stroke:#333當面臨分岔路口(多種轉換都能讓測試通過)時,選擇清單中較高位置的轉換,往往能導向更好的實作。若你被誘惑要合併兩個以上的轉換才能通過測試,可能遺漏了某些測試案例。遵循此優先順序會自然導向函數式程式設計風格的解決方案。
結論#
本章涵蓋了測試設計的多個面向:
- 從 GUI 與資料庫的測試策略
- 到測試模式(Test-Specific Subclass、Self-Shunt、Humble Object)
- 再到測試結構設計(打破一對一對應、介面層)
- 以及特定性與通用性的分歧演進
- 最後是轉換優先前提——指引 TDD 中 Green 步驟的理論框架
接下來的章節將探討 TDD 的第四條法則:重構(Refactoring)。