概述#

TDD 並非淺層技能——遵循三條法則只是起點。TDD 是一項深層技能,包含多層面向,需要數月甚至數年才能精通。本章探討測試設計中的幾個關鍵層面:從資料庫與 GUI 的測試策略、測試設計原則、測試模式,到深具啟發性的轉換優先前提(Transformation Priority Premise)


測試資料庫(Testing Databases)#

兩條核心規則#

  1. 不要測試資料庫本身——你可以假設資料庫是正常運作的
  2. 將資料庫與業務規則解耦——測試的重點是查詢(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)#

三條規則#

  1. 不要測試 GUI
  2. 測試 GUI 以外的所有東西
  3. 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 模式是一種妥協方案:

  • 承認有些程式碼無法實際被測試
  • 目標是讓那些程式碼簡單到不需要測試
  • 將跨邊界通訊的程式碼分為兩部分:PresenterHumble 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 類別,以及 RegularMovieChildrensMovie 子類別
7重新命名CustomerRentalCalculator——它才是保護測試免受底層類別變化影響的 facade

Figure 4.10: The result diagram

關鍵觀察#

  • RentalCalculatorTest 不知道 RentalMovieRegularMovieChildrensMovie 的存在(除了需要初始化的 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從無到有,函式回傳 nullTDD 起始的最退化測試
Nil → Constantnull 轉換為常數值return nullreturn new ArrayList<>()
Constant → Variable常數改為變數return 2return n
Unconditional → Selection加入 if 條件判斷注意不要讓條件太具體
Value → List單一值變為清單/容器elementelements[]
Selection → Iterationif 轉為 while 迴圈迭代是選擇的通用形式
Statement → Recursion單一語句變為遞迴呼叫常見於支援遞迴的語言
Value → Mutated Value變數值被修改(累加等)迴圈中的部分值累積

Fibonacci 範例#

作者以 Fibonacci 數列展示轉換的實際應用:

#轉換實作
1{} → Nil回傳 null
2Nil → Constant回傳 BigInteger.ONE
3Unconditional → Selection加入 if (n > 1) 分支
4Statement → Recursionfib(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:#333

Rule 14: 若某個轉換導致次優解,嘗試不同的轉換。

轉換優先順序#

作者相信存在一個大致的優先順序(但僅是前提,非數學證明):

優先順序轉換
1{} → Nil
2Nil → Constant
3Constant → Variable
4Unconditional → Selection
5Value → List
6Selection → Iteration
7Statement → Recursion
8Value → 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)