概述#

本章提出許多值得深思的問題,幫助你將 TDD 整合到自己的實踐中。有些問題很小,有些很大。有些答案在這裡,有些留給你探索。

步伐應該多大?#

這裡其實隱藏著兩個問題:

  • 每個測試應該涵蓋多少範圍?
  • 重構時應該經歷多少中間階段?

你可以寫測試讓每個測試只促進添加一行邏輯和少量重構,也可以寫測試讓每個促進添加數百行邏輯和數小時的重構。

重點: TDD 開發者隨時間的趨勢很明確——越來越小的步伐。但也有人在嘗試用應用層級的測試來驅動開發。

關於重構的步伐:

  • 剛開始時,準備好走很多小步驟。手動重構容易出錯,出錯越多、越晚發現,就越不願意重構
  • 做過 20 次手動重構後,嘗試省略一些步驟
  • 自動化重構大幅加速重構。原本 20 個手動步驟變成一個選單項目。數量上一個量級的變化通常構成質的變化——當你知道有優秀工具支持,就會在重構時更加大膽

什麼不需要測試?#

Phlip 提供了簡單的答案:「寫測試直到恐懼轉化為無聊。」

這是一個回饋循環,需要你自己找到答案。作為參考,你應該測試:

  • 條件判斷(Conditionals)
  • 迴圈(Loops)
  • 運算(Operations)
  • 多型(Polymorphism)

但只測試你自己寫的。除非有理由不信任,否則不要測試別人的程式碼。

技巧: 有時候為了特別小心,Kent Beck 會用測試來記錄外部程式碼的「不尋常行為」(也就是 bug),這樣如果 bug 被修復了,測試會失敗來通知你。

如何判斷測試品質好不好?#

測試就像煤礦中的金絲雀,透過它們的異常來揭示有害的設計氣息。以下測試屬性暗示設計有問題:

症狀暗示的設計問題
冗長的 setup 程式碼物件太大,需要拆分
setup 重複太多物件糾纏太緊,找不到共同 setup 的歸屬
執行時間過長的測試應用程式各部分難以獨立測試,需要用設計來解決
脆弱的測試應用程式的一部分驚人地影響了另一部分,需要消除遠距效應

補充: 相當於重力加速度 9.8 m/s^2 的常數是十分鐘的測試套件。超過十分鐘的套件最終一定會被修剪,或應用程式會被調校,讓套件回到十分鐘。

TDD 如何導向框架?#

悖論:不考慮程式碼的未來,反而讓程式碼更可能在未來具有適應性。

Kent Beck 從書中學到的是「為今天編碼,為明天設計」。TDD 似乎顛倒了:「為明天編碼,為今天設計」。實踐中發生的是:

  1. 第一個功能加入。簡單直接地實作,完成快速且缺陷少
  2. 第二個功能(第一個的變體)加入。兩個功能之間的重複被放在一處,差異則放在不同的地方(不同方法甚至不同類別)
  3. 第三個功能(前兩個的變體)加入。共同邏輯可能直接重用,獨特邏輯有明顯的歸屬
flowchart LR
    A["第一個功能<br/>簡單直接實作"] --> B["第二個功能<br/>發現重複<br/>提取共用邏輯"]
    B --> C["第三個功能<br/>共用邏輯可直接重用<br/>框架浮現"]

Open/Closed 原則(物件應對使用開放、對修改關閉)逐漸被滿足,而且恰好針對實踐中出現的那些變化類型。TDD 驅動開發留給你的框架擅長表達實際發生的變化,即便它們可能不擅長表達尚未發生的變化。

技巧: 三年後出現不尋常的變化怎麼辦?設計會在必要的位置快速演化。Open/Closed 原則暫時被違反,但因為有所有測試給你信心,這種違反成本不高。

需要多少回饋?#

需要寫多少測試?以一個簡單問題為例:給定三個整數代表三角形邊長,返回:

  • 1 表示等邊三角形
  • 2 表示等腰三角形
  • 3 表示不等邊三角形
  • 若三角形不合法則拋出例外

Kent Beck 寫了 6 個測試;Bob Binder 在《Testing Object-Oriented Systems》中為同樣的問題寫了 65 個。你需要從經驗和反思中決定寫多少。

思考 MTBF(Mean Time Between Failures,平均故障間隔時間)有助於決定測試數量。例如 Smalltalk 整數行為像真正的整數,不像 32-bit 計數器,所以測試 MAXINT 沒有意義。

重點: TDD 的測試觀是務實的。測試是手段而非目的——目的是我們對程式碼有高度信心。如果對實作的了解讓我們即使沒有測試也有信心,我們就不寫那個測試。這與黑箱測試不同——黑箱測試刻意忽略實作,展現測試本身有獨立價值的價值觀。

Kent Beck 的 Smalltalk 解法:

testEquilateral
  self assert: (self evaluate: 2 side: 2 side: 2) = 1

testIsosceles
  self assert: (self evaluate: 1 side: 2 side: 2) = 2

testScalene
  self assert: (self evaluate: 2 side: 3 side: 4) = 3

testIrrational
  [self evaluate: 1 side: 2 side: 3]
    on: Exception
    do: [:ex | ^self].
  self fail

testNegative
  [self evaluate: -1 side: 2 side: 2]
    on: Exception
    do: [:ex | ^self].
  self fail

testStrings
  [self evaluate: 'a' side: 'b' side: 'c']
    on: Exception
    do: [:ex | ^self].
  self fail

evaluate: aNumber1 side: aNumber2 side: aNumber3
  | sides |
  sides := SortedCollection
    with: aNumber1
    with: aNumber2
    with: aNumber3.
  sides first <= 0 ifTrue: [self fail].
  (sides at: 1) + (sides at: 2) <= (sides at: 3) ifTrue: [self fail].
  ^sides asSet size

何時應該刪除測試?#

更多測試更好,但如果兩個測試彼此冗餘,是否應該都保留?取決於兩個標準:

  1. 信心:永遠不要刪除會降低你對系統行為信心的測試
  2. 溝通:如果兩個測試走的是相同的程式碼路徑,但對讀者來說表達不同的場景,保留它們

如果兩個測試在信心和溝通方面都冗餘,刪除較不有用的那個。

程式語言和環境如何影響 TDD?#

嘗試在有 Refactoring Browser 的 Smalltalk 中做 TDD,再在用 vi 的 C++ 中做 TDD。體驗會有何不同?

在 TDD 循環(測試/編譯/執行/重構)較難取得的語言和環境中,你可能會傾向於:

  • 每個測試涵蓋更多範圍
  • 重構時經歷較少中間步驟

在 TDD 循環豐富的語言和環境中,你可能會嘗試更多實驗。

TDD 能擴展到巨大系統嗎?#

Kent Beck 參與過的最大 TDD 系統是 LifeWare(www.lifeware.ch)。四年、40 人年後,系統包含約 250,000 行功能程式碼和 250,000 行測試程式碼(Smalltalk),有 4,000 個測試,在 20 分鐘內執行完畢,每天執行數次。

補充: 系統中的功能數量似乎不影響 TDD 的有效性。透過消除重複,你傾向於建立更多更小的物件,這些物件可以獨立測試,與應用程式的大小無關。

能用應用層級的測試驅動開發嗎?#

用小規模測試(單元測試)驅動開發的問題是:你可能實作了你認為使用者想要的,結果卻不是他們要的。如果我們在應用層級撰寫測試,使用者(在協助下)就能自己撰寫測試。

技術問題是 fixture——如何為尚不存在的功能撰寫和執行測試?通常的出路是引入一個直譯器,當遇到不知如何解譯的測試時優雅地發出錯誤訊號。

社會問題是 ATDD(Application Test-Driven Development)要求使用者承擔新的責任——在實作開始前撰寫測試。組織會抵抗這種責任轉移。

注意: TDD 完全在你的控制之下,你今天就可以開始使用。混合 red/green/refactor 的節奏、應用 fixture 的技術問題、以及使用者撰寫測試的組織變革問題,不太可能成功。One Step Test 規則適用:先在自己的實踐中讓 red/green/refactor 運轉起來,再推廣。

ATDD 的另一個面向是測試到回饋之間的時間長度。如果客戶寫了測試,十天後才通過,你大部分時間都盯著紅條。因此仍然需要程式設計師層級的 TDD,以獲得即時的綠條並簡化內部設計。

如何在開發中途切換到 TDD?#

你有一堆大致能用的程式碼,想要用 TDD 驅動新程式碼。怎麼做?

最大的問題是:沒有為測試而寫的程式碼通常不太容易測試。 介面沒有被設計過,難以隔離一小段邏輯來執行和檢查結果。

「那就修好它」——但沒有自動化工具的重構容易出錯,而你又沒有測試來捕捉錯誤。雞生蛋蛋生雞。

不要做的事: 不要去為整個系統寫測試和重構整個系統。那會花幾個月,期間沒有新功能。花錢不賺錢通常不是永續的做法。

正確的策略:

  1. 限制變更範圍:看到可以大幅簡化但目前不需要改變的部分,放著不動
  2. 打破測試與重構的僵局:透過其他方式獲得回饋——非常小心地工作、結對程式設計、粗粒度的系統測試
  3. 漸進式改善:隨時間推移,經常變動的部分會逐漸看起來像是 TDD 驅動的。偶爾會走進未照亮的暗巷被伏擊,提醒你以前有多慢。然後放慢腳步,打破僵局,重新出發

TDD 的目標對象是誰?#

每種程式設計實踐都編碼了一套價值體系。TDD 建立在一個迷人的天真假設上:如果你寫出更好的程式碼,你就會更成功。

Kent Beck 坦言這可能過於天真。好的工程可能只佔專案成功的 20%。差的工程肯定會沈沒專案,但適度的工程就能讓專案成功,只要其他 80% 對齊。從這個角度看,TDD 是過度設計(overkill)。但它確實讓你能寫出缺陷遠少於業界常態、設計更乾淨的程式碼。

重點: TDD 也適合對程式碼有感情的技術人員。Kent Beck 年輕時最大的挫敗之一是看著程式碼庫隨時間腐化。TDD 讓你隨時間對程式碼越來越有信心。隨著測試累積和設計精煉,越來越多的改變成為可能。目標是一年後對專案的感覺比一開始還好。

TDD 對初始條件敏感嗎?#

以某種順序處理測試似乎運作得非常流暢。同樣的測試以不同順序實作,似乎找不到小步前進的方式。一個測試序列真的比另一個快一個數量級嗎?這是因為實作技巧不夠?還是測試本身有什麼暗示應該以某種順序處理?

如果 TDD 在小尺度上對初始條件敏感,那在大尺度上是否可預測?(就像密西西比河的小漩渦不可預測,但河口每秒 200 萬立方英尺的流量是可以依賴的。)

TDD 與模式的關係#

Kent Beck 的技術寫作一直在尋找基本規則,讓行為近似專家。他不是在尋找機械式遵循的規則。

將可重複的行為簡化為規則後,應用規則變成機械化的,這比每次從第一原理重新辯論快得多。當遇到例外或不符合任何規則的問題時,你有更多時間和精力來產生和應用創造力。

TDD 與模式的另一個關係是:TDD 作為模式驅動設計的實作方法。假設我們決定要使用 Strategy,先為第一個變體寫測試並實作為方法,然後有意識地為第二個變體寫測試,期望重構階段驅動我們走向 Strategy。

注意: Robert Martin 和 Kent Beck 對這種風格進行了研究,問題在於設計總是令人驚訝。完全合理的設計想法最後證明是錯的。不如只思考你希望系統做什麼,讓設計在之後自行梳理。

TDD 為什麼有效?#

假設 TDD 確實幫助團隊高效建構鬆耦合、高內聚、低缺陷率、低維護成本的系統。這怎麼可能發生?

減少缺陷是效果的一部分。越早發現和修復缺陷,成本越低。減少缺陷帶來大量次級心理和社會效應:

  • 程式設計實踐變得壓力更小——不需要同時擔心所有事情
  • 與隊友的關係更正面——不再破壞建置
  • 客戶對系統更正面——新版本意味著更多功能,而非一堆新缺陷

縮短回饋循環也是一個原因。實作決策的回饋循環明顯很短——數秒或數分鐘。設計決策的回饋循環在設計想法和第一個體現該想法的測試之間,回饋也在數秒或數分鐘內到來,而非等待數週或數月。

Phlip 提出了一個更奇特的解釋——**吸引子(attractor)**概念:

採用能將正確程式碼作為極限函數「吸引」過來的程式設計實踐。如果你為每個功能寫單元測試,在每一步之間重構以簡化程式碼,一次只添加一個功能且只在所有單元測試通過後才添加,你就會創建數學家所說的「吸引子」。這是狀態空間中所有流都匯聚的點。程式碼隨時間更可能變好而非變壞;吸引子以極限函數的方式趨近正確性。

TDD 與 Extreme Programming 的關係#

以下是 XP 各實踐如何與 TDD 相互增強的摘要:

XP 實踐與 TDD 的相互增強
Pairing(結對程式設計)TDD 測試是絕佳的對話素材,避免搭檔在解決不同問題;疲勞時提供接手的搭檔
Work fresh(保持精力)無法讓下一個測試通過時就該休息
Continuous integration(持續整合)測試讓你能更頻繁地整合(15-30 分鐘一個循環)。如 Bill Wake 所說:「n^2 問題在 n 永遠是 1 時不是問題」
Simple design(簡單設計)只為測試編寫所需的程式碼並移除所有重複,自動得到完美適應當前需求的設計
Refactoring(重構)測試給你信心做更大規模的重構;重構讓下一輪測試更容易寫
Continuous delivery(持續交付)TDD 改善系統 MTBF,能更頻繁地將程式碼投入生產而不影響客戶

名稱的由來#

  • Development:舊的「階段主義」思維被弱化,因為決策之間的回饋在時間上分離時很困難。Development 意味著分析、邏輯設計、實體設計、實作、測試、審查、整合和部署的複雜舞蹈
  • Driven:Kent Beck 曾稱 TDD 為「test-first programming」,但「first」的反面是「last」,很多人確實在程式設計後才測試。命名規則是反面應該至少令人隱約不滿——如果不用測試來驅動開發,你用什麼驅動?推測?規格?(注意這兩個英文字 speculation/specification 同源)
  • Test:自動化、具體化的測試。按個按鈕它們就執行。TDD 的諷刺之一是它不是一種測試技術(Cunningham Koan)。它是分析技術、設計技術,真正是一種組織所有開發活動的技術

附錄摘要#

影響圖(Influence Diagrams)#

影響圖的概念來自 Gerald Weinberg 的《Quality Software Management》系列,用於觀察系統元素如何相互影響。三個元素:

元素說明
活動一個詞或短語
正向連結有向箭頭,表示來源活動越多,目標活動越多
負向連結帶圈的有向箭頭,表示來源活動越多,目標活動越少

循環中負向連結的數量決定回饋類型:偶數個是正回饋(加速成長),奇數個是負回饋(抑制活動)。

系統設計的關鍵:

  • 創建良性循環(正回饋鼓勵好活動的成長)
  • 避免死亡螺旋(正回饋鼓勵有害活動的成長)
  • 創建負回饋循環(防止好活動被過度使用)

Fibonacci 範例#

一個完全由測試驅動的 Fibonacci 實作範例。從最簡單的常數開始,逐步泛化:

public void testFibonacci() {
  int cases[][]= {{0,0},{1,1},{2,1},{3,2}};
  for (int i= 0; i < cases.length; i++)
    assertEquals(cases[i][1], fib(cases[i][0]));
}

int fib(int n) {
  if (n == 0) return 0;
  if (n == 1) return 1;
  return fib(n-1) + fib(n-2);
}

推導過程:先用常數讓測試通過,再用特殊情況處理,最後以 1 + 1fib(n-1) + 1fib(n-1) + fib(n-2) 的方式逐步泛化。

Martin Fowler 的後記#

TDD 最難傳達的是它帶來的心理狀態。TDD 讓你有一次只保持一個球在空中的感覺,因此能更專注。

Fowler 識別出三種單邏輯模式(monological modes)的程式設計:

模式專注於不擔心
Test-first讓測試通過設計
Refactoring正確的設計新功能
Pattern copying模式的適配問題本身

重點: TDD 也許暗示了一種將程式設計拆解為基本模式的方法,透過在模式之間快速切換來避免單調。單邏輯模式與切換的結合,帶來專注的好處並降低大腦的壓力,同時避免流水線的單調。