本章回顧整個 Money 範例,從流程到結果,涵蓋六個面向:後續工作、隱喻的力量、JUnit 使用方式、程式碼度量、開發流程,以及測試品質。

What’s Next?#

程式碼完成了嗎?沒有。 Sum.plus()Money.plus() 之間仍有重複。如果將 Expression 從介面改為類別(不常見的方向,通常是類別演變為介面),就能為共用程式碼找到自然的歸屬。

作者不相信「完成」這個概念。TDD 可以用來追求完美,但那不是它最有效的用途。經常修改的核心部分應該堅若磐石,讓你能自信地進行日常變更;而系統邊緣不常改動的部分,測試可以稀疏些、設計可以醜些,不會影響你的信心。

當所有明顯的工作都做完後,可以考慮的下一步:

  • 執行程式碼分析工具(如 SmallLint):自動化工具不會忘記,能指出你已知但還沒處理的問題
  • 思考「不該通過」的測試:如果一個你認為會失敗的測試竟然通過了,你需要找出原因
  • 清單空了就回顧設計:概念和命名是否協調?是否有難以消除的重複?殘留的重複往往是潛在設計問題的症狀

Metaphor 的力量#

作者在撰寫本書時意外地使用了 Expression(運算式) 作為隱喻,讓設計走向了全新的方向。

過去 Ward Cunningham 用 向量(vector) 來表示「多種貨幣的組合」。作者也用過 MoneySum、MoneyBag、Wallet 等名稱。這些隱喻都暗示集合是扁平的——例如 “2 USD + 5 CHF + 3 USD” 會合併為 “5 USD + 5 CHF”。

而 Expression 隱喻讓作者擺脫了合併重複貨幣的麻煩問題,程式碼比過去任何一次都更乾淨、更清楚。雖然對 Expression 的效能有些擔憂,但作者傾向等到有實際使用數據後再優化。

重點: 隱喻不只是取名的來源,它深刻地影響設計的結構方向。選擇不同的隱喻,可能導向截然不同的設計。

JUnit 使用方式#

作者在撰寫 Money 範例時讓 JUnit 記錄了操作日誌:

  • 總共按了 125 次 Run 按鈕
  • 在純寫程式的時段,大約每分鐘執行一次測試
  • 整個過程中只有一次對結果感到意外,那次是在匆忙中做的重構

Figure 17.1: Histogram of the time interval between test runs

長間隔的大量出現很可能是因為同時在寫書。

Code Metrics#

指標正式程式碼測試程式碼
類別數51
函式數2215
行數9189
Cyclomatic Complexity1.041
行數/函式4.15.9

幾個值得注意的觀察:

  • 測試程式碼和正式程式碼的行數幾乎相同(89 vs 91)
  • 測試的 cyclomatic complexity 為 1,因為測試中沒有分支或迴圈
  • 正式程式碼的 complexity 也很低(1.04),因為大量使用多型來取代顯式的控制流程
  • 測試中每個函式行數偏高(5.9),是因為尚未抽取共用的 fixture 建立程式碼

Process#

TDD 的循環:

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

假設寫測試算一步,那每個測試需要多少次變更才能完成編譯、執行、重構的全部流程?

Figure 17.2: Number of changes per refactoring

作者預期在大型專案中,讓測試編譯並通過所需的變更數量會維持在很小的範圍。但重構所需的變更數量可能呈現「肥尾」(fat tail / leptokurtotic)分佈——類似鐘形曲線,但有更多極端值,如同股市的價格變動。

Test Quality#

TDD 自然產出的測試足以在系統運行期間持續使用,但不能取代其他類型的測試

  • 效能測試(Performance)
  • 壓力測試(Stress)
  • 可用性測試(Usability)

語句覆蓋率(Statement Coverage)#

嚴格遵循 TDD 應該能達到 100% 語句覆蓋率。JProbe 報告只有一行未被測試覆蓋——Money.toString(),那是特意加入的除錯輔助方法。

缺陷插入(Defect Insertion)#

另一種評估測試品質的方式:改變一行程式碼的意義,應該要有測試因此失敗。Jester 工具報告只有一行能在不破壞測試的情況下被修改——Pair.hashCode()。因為我們的實作本來就回傳固定值 0,換成其他常數並不會改變程式的語義。

提升覆蓋率的兩種途徑#

覆蓋率的粗略度量 = 測試涵蓋的面向 / 需要測試的面向(邏輯複雜度)。

  1. 寫更多測試:專業測試人員為同一個問題可能寫出 65 個測試,而 TDD 開發者只寫 6 個
  2. 簡化程式碼邏輯:重構步驟經常有這個效果——用多型取代條件判斷,甚至完全消除分支

技巧: 用 Phlip 的話說:「與其增加測試來走遍所有輸入排列組合,不如讓同樣的測試覆蓋不斷縮小的程式碼排列組合。」

One Last Review#

學習 TDD 時反覆出現的三個驚喜:

  1. 讓測試通過的三種方法:Fake It(偽造)、Triangulation(三角測量)、Obvious Implementation(顯而易見的實作)
  2. 消除測試與程式碼之間的重複來驅動設計
  3. 控制測試之間的步伐大小:路滑時放慢腳步增加牽引力,路況好時加速巡航