核心觀點#

本章探討的不只是測試的驗證功能,而是撰寫測試對於軟體設計架構產生的深遠影響。作者引用 Seneca 的名言開篇,指出撰寫單元測試的行為本身更像是一種設計行為,而非單純的驗證行為。

重點: 撰寫單元測試關閉了許多回饋迴路(feedback loops),其中功能驗證反而是最不重要的一個。測試對設計的影響遠大於對驗證的貢獻。

Test-Driven Development#

TDD 遵循三條簡單規則:

  1. 不寫任何 production code,除非已有一個失敗的 unit test
  2. 不寫超過剛好足以失敗(或無法編譯)的 unit test
  3. 不寫超過剛好足以讓失敗測試通過的 production code

以這種方式工作,開發者會在極短的循環內交替進行——每一兩分鐘就在寫測試和寫程式碼之間切換。

flowchart LR
    A["🔴 寫一個<br/>失敗的測試"] --> B["🟢 寫剛好足以<br/>通過的程式碼"]
    B --> C["🔵 重構<br/>改善結構"]
    C --> A

TDD 帶來的四大效益#

  • 驗證的安全網:每個功能都有對應的測試,修改程式碼時不怕破壞既有功能,讓開發者更自由地改進系統
  • 迫使採用呼叫者視角:先寫測試迫使我們從程式的使用者角度出發,關注介面(interface)而非實作。這讓軟體被設計為 conveniently callable(方便呼叫的)
  • 迫使解耦合:為了讓軟體可測試,它必須與周遭環境解耦。先寫測試自然會 force us to decouple the software
  • 活的文件:測試本身就是最好的範例文件,可編譯、可執行、永遠保持最新,不會說謊

Test-First Design 範例:Hunt the Wumpus#

作者以 Hunt the Wumpus 遊戲為例,展示了 intentional programming(意圖式程式設計)的概念:

[Test]
public void TestMove()
{
  WumpusGame g = new WumpusGame();
  g.Connect(4,5,"E");
  g.GetPlayerRoom(4);
  g.East();
  Assert.AreEqual(5, g.GetPlayerRoom());
}
  • 先在測試中表達你的意圖,讓它盡可能簡單易讀
  • 相信自己能寫出符合測試所暗示之結構的程式碼
  • 測試揭示了一個有趣的設計決策:不需要 Room 類別,連結(connection)的概念比「房間」更核心

技巧: 先寫測試是一種辨別設計決策的行為(The act of writing tests first is an act of discerning between design decisions)。它在非常早期就照亮了核心設計議題。

Test Isolation 與 Mock Object#

先寫測試常常揭露軟體中應該被解耦的地方。以薪資系統(Payroll)為例:

Figure 4.1: Coupled payroll model

原始設計中 Payroll 類別直接依賴 EmployeeDatabaseCheckWriterEmployee,這使得測試變得困難:

  • 需要真實的資料庫
  • 無法自動化驗證印出的支票

解決方案是使用 Mock Object 模式:在所有協作者之間插入介面,並建立實作這些介面的測試替身。

Figure 4.2: Decoupled Payroll using MOCK OBJECTS for testing

[Test]
public void TestPayroll()
{
  MockEmployeeDatabase db = new MockEmployeeDatabase();
  MockCheckWriter w = new MockCheckWriter();
  Payroll p = new Payroll(db, w);
  p.PayEmployees();
  Assert.IsTrue(w.ChecksWereWrittenCorrectly());
  Assert.IsTrue(db.PaymentsWerePostedCorrectly());
}

Serendipitous Decoupling(意外的解耦)#

這種為了測試而進行的解耦,同時帶來了架構上的好處——允許替換不同的資料庫和支票列印器。

重點: Writing tests before code improves our designs. 為了隔離被測模組而進行的解耦,會以有益於整體程式結構的方式迫使我們解耦。

Acceptance Tests#

Unit test 是白箱測試(white box test),驗證系統的個別機制。Acceptance test 是黑箱測試(black box test),驗證客戶需求是否被滿足。

Acceptance test 的關鍵特徵:

  • 由不了解系統內部機制的人撰寫(客戶、業務分析師、QA)
  • 使用非技術人員也能讀寫的特殊規格語言
  • 自動化執行
  • 是功能的終極文件——the acceptance tests become the true requirements document

注意: 在專案早期用手動方式執行 acceptance test 是不明智的,因為這會喪失自動化測試所帶來的解耦壓力。從第一個 iteration 就應該自動化 acceptance test。

作者使用開源工具 FitNesse 撰寫 acceptance test,每個測試都是一個簡單的網頁,可從瀏覽器存取和執行。

Serendipitous Architecture(意外的架構)#

Acceptance test 對系統架構施加了壓力。以薪資系統的第一個 iteration 為例:

Figure 4.3: Sample acceptance test

考慮 acceptance test 的需求,自然導向了好的架構決策:

  • 系統需要一個 API 供 FitNesse 呼叫
  • UI 必須與 business rules 解耦
  • 支票列印必須與 Create Paychecks 功能解耦

補充: 正如 unit test 在小範圍(in the small)驅動出好的設計決策,acceptance test 在大範圍(in the large)驅動出好的架構決策。

結論#

  • 測試越簡單、執行越頻繁,偏差就越快被發現
  • 系統一旦運作到某個水準,就永遠不會倒退
  • 驗證只是撰寫測試的好處之一;unit test 和 acceptance test 都是可編譯、可執行的文件
  • 對架構和設計的影響可能是最重要的好處:越可測試的東西就越解耦,考慮全面測試的行為對軟體結構有深遠的正面影響