曾經有一段時間,我們團隊對TDD又愛又恨。「寫測試讓我們更有信心」,但「重構時要改一堆測試,還不如不寫」。這種矛盾讓我們反覆懷疑:TDD到底有沒有用?

深入研究Kent Beck的原著和Valentina Jemuović的演講後,才發現問題出在我們誤解了「測試單元」是什麼。

痛苦的根本原因

許多團隊學TDD時,都被教導「每個class寫一個test class,每個method寫一個test method」。這個看似合理的原則,埋下了長期的痛苦。

問題在於,這樣的測試耦合到了程式的結構,而非行為。只要重構——把一個class拆成兩個、把方法提取到新類別——測試就跟著破裂。維護測試的時間甚至超過寫功能本身。

Kent Beck在《Test Driven Development By Example》第一頁就寫道:

“Programmer tests should be sensitive to behavior changes and insensitive to structure changes.”

測試應該對行為的改變敏感,對結構的改變不敏感。如果重構時測試跟著爆炸,原因就在這裡。

測試是可執行的需求規格

需要先轉換一個根本認知:測試不是「驗證實作正確的工具」,而是用程式碼表達的需求規格書

需求定義系統「應該做什麼」,實作是「怎麼做」的一種方式。需求應該保持穩定,實作可以隨時改變。Martin Fowler在《Refactoring》中說:

“Refactoring is a way of restructuring an existing body of code, altering its internal structure without changing its external behavior.”

重構改變內部結構,不改變外部行為。耦合到行為的測試,在重構時自然保持穩定。

Sociable Unit Tests:把Module當作測試單元

TDD有兩種截然不同的流派。

Classical TDD(Kent Beck、Martin Fowler的做法)把Unit定義為Module——一個或多個協同工作的類別組合,對外提供清晰的Public API。測試只透過這個Public API互動,不知道Module內部有哪些類別、它們如何協作。唯一需要Mock的是真正的外部依賴:資料庫、檔案系統、外部服務。這種風格稱為Sociable Unit Tests

Mockist TDD(London School)把Unit定義為單一Class,Mock所有協作者。這種風格稱為Solitary Unit Tests

核心差異在耦合對象:

1Sociable: Test → [Module API] → Module Implementation(黑盒)
2Solitary: Test → Mock(B) → Class A → Class B
3                 Mock(C)           → Class C

Sociable只有一條耦合線,Solitary有多條。每一條耦合線都是日後的維護成本。

重構安全性的驗證

判斷自己的測試是Sociable還是Solitary,有個簡單的驗證方法:

改變Module的內部邏輯、調整類別結構、重新命名內部方法。如果所有測試依然通過,不需要修改,那你寫的是Sociable(正確)。如果任何測試需要跟著改,那你寫的是Solitary(需要重新設計)。

以一個訂單提交的例子來說,Sociable測試看起來像這樣:

 1test('使用者提交訂單成功', () async {
 2  // Given: Mock外部依賴(只Mock Repository)
 3  when(mockRepository.save(any))
 4      .thenAnswer((_) async => SaveResult.success('order-123'));
 5
 6  // When: 透過Use Case API提交訂單
 7  final result = await submitOrderUseCase.execute(order);
 8
 9  // Then: 驗證可觀察的行為結果
10  expect(result.isSuccess, true);
11  expect(result.orderId, 'order-123');
12  // 測試不知道Order內部如何計算、驗證
13  // 測試使用真實的Domain Entities
14});

而Solitary測試會是:

 1test('OrderService.submitOrder calls Repository.save', () async {
 2  // Given: Mock所有協作者
 3  final mockOrder = MockOrder();          // 連Order也Mock了
 4  final mockValidator = MockOrderValidator();
 5  final mockCalculator = MockPriceCalculator();
 6
 7  when(mockValidator.validate(mockOrder)).thenReturn(true);
 8  when(mockCalculator.calculate(mockOrder)).thenReturn(100);
 9  when(mockRepository.save(mockOrder))
10      .thenAnswer((_) async => SaveResult.success('order-123'));
11
12  // Then: 驗證方法呼叫次數(實作細節)
13  verify(mockRepository.save(mockOrder)).called(1);
14  // 這個測試一旦重構OrderService的內部邏輯就會破裂
15});

Test-First的速度優勢

Test-First(先寫測試)比Test-Last(先寫程式再補測試)快,原因是問題被發現的時間點更早。

Test-First的Red-Green-Refactor循環強迫你在寫實作之前先思考介面:「這個功能怎麼用?」、「測試容不容易寫?」介面設計問題在寫測試時(最早期)就暴露,修復成本最低。

Test-Last則是程式寫完了才發現難以測試,這時通常意味著設計有問題,要改動的範圍更大。Kent Beck說TDD更快,指的正是這個。

BDD不是新方法,是修正命名

Dan North在2006年創造「BDD」,目的是修正TDD命名造成的混淆。

他發現「Test」這個詞讓開發人員誤以為要測試每個類別和方法,於是用「Behavior」取代,讓意圖更清楚:測試的是行為,不是程式結構。這和Kent Beck 2003年說的完全一致,只是換了個能讓人更直覺理解的詞。

Google在《Software Engineering at Google》中也驗證同樣的結論:「Don’t write a test for each method. Write a test for each behavior.」

與Clean Architecture的結合

Sociable Unit Tests和Clean Architecture是天然的組合,因為建立在相同原則上:業務邏輯獨立於外部世界。

在Clean Architecture中,Use Cases層是業務邏輯的進入點,對外提供清晰的API,對內只使用Domain Entities和透過介面隔離的外部依賴(Repository、Gateway等)。這個結構天然對應Sociable的需求:Use Cases的Public API就是測試邊界,Domain Entities用真實物件,只有Repository需要Mock。

更重要的是,對Use Cases的Unit Test同時就是業務驗收測試。一個寫著「使用者提交訂單成功」的案例,不需要啟動UI也不需要真實資料庫,但驗證了完整的業務流程。Alistair Cockburn在提出Hexagonal Architecture時說:「Tests are another user of the system.」

並非所有情況都適合Sociable。數學演算法、加密系統這類需要細粒度驗證的場景,精確定位到具體類別比重構穩定性更重要,用Solitary合理。但大多數商業應用不是這類。

結論

我們曾以為TDD很痛苦,但那是因為我們測試的是程式長什麼樣子,而不是它做什麼

正確的做法只有一句話:測試透過Module的Public API互動,只Mock真正的外部依賴,使用真實的Domain Entities。

這樣的測試在重構時保持穩定,在功能改變時精準報警。Kent Beck、Dan North、Martin Fowler在不同年代說的是同一件事:測試行為,而非結構


參考資料:

  • Kent Beck,《Test Driven Development By Example》,2003
  • Martin Fowler,《Refactoring: Improving the Design of Existing Code》,1999
  • Dan North,《Introducing BDD》,2006
  • Google,《Software Engineering at Google》,2020
  • Valentina (Cupać) Jemuović,TDD and Clean Architecture - Driven by Behaviour