單元測試實務#

本章建立在第 10 章的原則之上,介紹日常編程中可以實際應用的單元測試技巧。好的單元測試應具備以下特質:

  • 準確偵測破損 – 程式壞了測試就該失敗,且不應產生誤報
  • 不受實作細節影響 – 重構不應導致測試失敗
  • 失敗訊息清晰明瞭 – 測試失敗時能清楚說明問題所在
  • 測試程式碼易於理解 – 其他工程師能看懂測試的目的與做法
  • 容易且快速執行 – 工程師需要經常執行測試,緩慢的測試浪費大量時間

11.1 測試行為,而非只測函式#

測試一段程式碼就像執行一份待辦清單。常見的錯誤是將「函式名稱」當作待辦項目 – 一個類別有兩個函式就只寫兩個測試案例。問題在於:

  • 一個函式往往展現多種行為
  • 一個行為有時橫跨多個函式
  • 只為每個函式寫一個測試,很可能遺漏重要行為

11.1.1 每個函式只寫一個測試往往不足夠#

以房貸評估系統為例,MortgageAssessor.assess() 函式包含多種行為:

  • 判斷客戶是否有資格(良好信用、無現有房貸、未被禁止)
  • 計算最高貸款金額(年收入減去年支出,乘以 10)

如果只寫一個測試案例測試 assess() 函式,只能驗證「核准」的情境,卻無法測試所有拒絕原因。修改程式讓被禁止的客戶也能通過,測試仍然會通過!

11.1.2 解法:專注於測試每個行為#

將行為逐一列出並為每個行為撰寫測試案例。以 MortgageAssessor 為例,至少需要測試:

  • 信用評等不佳時,申請被拒絕
  • 已有現有房貸時,申請被拒絕
  • 被公司禁止時,申請被拒絕
  • 申請核准時,最高貸款金額 = (收入 - 支出) x 10
  • 不同的收入與支出數值(含邊界值,如零收入、極高金額)

一個 100 行的程式碼對應 300 行的測試程式碼是完全正常的。如果測試程式碼量沒有超過實際程式碼量,這往往是一個警訊。

檢查每個行為是否都已測試的方法:

  • 是否有任何程式碼行可以刪除後,程式仍能編譯且測試通過?
  • 是否有任何 if 陳述式的極性可以反轉後測試仍通過?
  • 是否有任何邏輯或算術運算子可以替換後測試仍通過(例如 && 換成 ||)?
  • 是否有任何常數或硬編碼值可以更改後測試仍通過?

Mutation testing(突變測試)可以自動化這項檢查。工具會產生程式碼的變異版本,如果測試在變異後仍然通過,就代表並非每個行為都被妥善測試了。

別忘記錯誤情境: 程式碼在錯誤發生時的行為也是重要的行為,必須被測試。例如 BankAccount.debit() 在收到負數金額時拋出 ArgumentException,這個行為也需要測試。

mindmap
root(("assess()"))
拒絕
信用不良
已有房貸
黑名單客戶
核准
計算額度
(收入 - 支出) x 10

11.2 避免僅為了測試而公開內部實作#

類別通常有公開函式(public API)和私有函式(private functions)。私有函式是實作細節,外部程式碼不應直接存取。有時為了方便測試而公開私有函式,但這通常不是好主意。

11.2.1 測試私有函式通常是壞主意#

MortgageAssessor 為例,如果為了測試「信用不佳時申請被拒」而將 isEligibleForMortgage() 改為公開,會產生三個問題:

  1. 測試的不是我們真正關心的行為 – 測試的是 isEligibleForMortgage() 回傳 false,而不是房貸申請最終被拒絕。有人可能修改 assess() 使其不再呼叫此函式,測試仍會通過
  2. 測試與實作細節緊耦合 – 重新命名函式或將其移到輔助類別就會導致測試失敗
  3. 改變了類別的公開 API – 「僅供測試」的註解很容易被忽略,其他工程師可能開始依賴此函式

Figure 11.1: The relationship between the test code and the code under test.

11.2.2 解法:優先透過公開 API 進行測試#

透過呼叫 MortgageAssessor.assess() 來測試「信用不佳的客戶申請被拒」這個行為:

testAssess_badCreditRating_mortgageRejected() {
  // 設定一個信用不佳的客戶
  // 呼叫 assess()
  // 斷言 decision.isApproved() 為 false
}

這樣測試的是真正重要的行為,而非實作細節,且無需公開任何私有函式。

若一個行為很重要且我們真正關心它,就應該被測試。但應透過公開 API 測試,而非直接測試私有函式。

11.2.3 解法:將程式碼拆分為更小的單元#

當類別過於龐大,透過公開 API 測試所有行為變得困難時,這通常是抽象層「太厚」的警訊。解決方案是將程式碼拆分為更小的類別。

例如,將信用評等檢查邏輯抽取為獨立的 CreditRatingChecker 類別:

  • MortgageAssessor 依賴 CreditRatingChecker,大幅簡化
  • CreditRatingChecker 有自己的公開 API(isCreditRatingGood()
  • 兩個類別都可以透過各自的公開 API 輕鬆測試

Figure 11.2: Splitting a big class into smaller classes can make the code more testable.


11.3 一次只測試一個行為#

一段程式碼通常有多個行為需要測試。雖然有時候可以設計一個情境同時測試多個行為,但這通常不是好主意。

11.3.1 同時測試多個行為會導致糟糕的測試#

getValidCoupons() 函式為例,它有以下行為:

  • 只回傳有效的優惠券
  • 已兌換的優惠券被排除
  • 已過期的優惠券被排除
  • 發給不同客戶的優惠券被排除
  • 回傳的優惠券按價值降序排列

如果寫一個龐大的測試 testGetValidCoupons_allBehaviors 同時測試所有行為:

  • 程式碼難以理解 – 測試名稱不夠具體,程式碼量大且難以追蹤
  • 失敗訊息無法解釋問題 – 只能知道「某些東西壞了」,但無法快速判斷是哪個行為出了問題

Figure 11.3: Testing multiple behaviors in one go can result in poorly explained test failures.

11.3.2 解法:每個行為使用獨立的測試案例#

為每個行為撰寫命名清楚的獨立測試案例:

  • testGetValidCoupons_validCoupon_included()
  • testGetValidCoupons_alreadyRedeemed_excluded()
  • testGetValidCoupons_expired_excluded()
  • testGetValidCoupons_issuedToDifferentCustomer_excluded()
  • testGetValidCoupons_returnedInDescendingValueOrder()

好處:

  • 每個測試案例的程式碼更簡潔、易於理解
  • 從測試名稱就能立即知道測試的是什麼行為
  • 失敗時能清楚指出哪個行為壞了

Figure 11.4: Testing one behavior at a time often results in well-explained test failures.

參數化測試(Parameterized Tests) 可以在不重複大量程式碼的情況下,針對不同值組合測試各個行為。各框架的支援方式不同:NUnit 的 TestCase attribute、JUnit 的 parameterized tests、Jasmine 的自訂方式等。記得為每組參數命名,以確保失敗訊息清楚明瞭。


11.4 適當使用共享測試設置#

測試案例通常需要一些設置工作(建構相依性、填充測試資料等)。測試框架提供了共享設置的機制:

  • BeforeAll(或 OneTimeSetUp)– 在所有測試案例執行前執行一次
  • BeforeEach(或 SetUp)– 在每個測試案例執行前各執行一次
  • AfterAll(或 OneTimeTearDown)– 在所有測試案例執行後執行一次
  • AfterEach(或 TearDown)– 在每個測試案例執行後各執行一次

Figure 11.5: Testing frameworks often provide a way to run setup and teardown code at various times relative to the test cases.

sequenceDiagram
participant TestRunner
participant Setup
participant TestCase

    TestRunner->>Setup: BeforeAll (執行一次)
    activate Setup
    Setup-->>TestRunner: 完成
    deactivate Setup

    loop 每個測試案例
        TestRunner->>Setup: BeforeEach (每次執行前)
        activate Setup
        Setup-->>TestRunner: 完成
        deactivate Setup

        TestRunner->>TestCase: Arrange-Act-Assert
        activate TestCase
        TestCase-->>TestRunner: 測試結果
        deactivate TestCase

        TestRunner->>Setup: AfterEach (每次執行後)
        activate Setup
        Setup-->>TestRunner: 完成
        deactivate Setup
    end

    TestRunner->>Setup: AfterAll (結束時執行一次)
    activate Setup
    Setup-->>TestRunner: 完成
    deactivate Setup

共享設置有兩種重要但不同的形式:

  • 共享狀態(Sharing state)BeforeAll 設置的狀態在所有測試案例之間共享
  • 共享配置(Sharing configuration)BeforeEach 設置的配置在每個測試案例間不共享狀態,但共享配置邏輯

11.4.1 共享可變狀態可能造成問題#

測試案例之間應該彼此隔離,一個測試案例的行為不應影響其他測試案例的結果。

OrderManager 測試為例,如果在 BeforeAll 中建立一個 Database 實例並在所有測試案例間共享:

  1. 第一個測試案例將訂單標記為「延遲」存入資料庫
  2. 第二個測試案例即使程式碼有 bug 未正確存值,資料庫中仍有前一個測試留下的值,導致斷言仍然通過

共享可變狀態是測試中最常見的問題之一。前一個測試案例的副作用可能讓後續測試在程式碼壞掉時仍然通過,形成虛假的安全感。

11.4.2 解法:避免共享狀態或在測試間重置#

  • 最佳做法:不共享狀態,為每個測試案例建立新實例
  • 使用 Test Double:如果有 FakeDatabase,建立成本低,每個測試案例可用新實例
  • 不得已時:在 AfterEach 區塊中重置共享狀態
@AfterEach
void tearDown() {
    database.reset();  // 每個測試案例後重置資料庫
}

全域狀態(Global State)也會在測試案例間造成共享。如果被測程式碼維護任何全域狀態,測試程式碼也需要在測試案例之間重置它。這是避免使用全域狀態的另一個好理由。

11.4.3 共享配置也可能造成問題#

即使不共享狀態,共享配置也可能導致測試失效。以 OrderPostageManager 為例:

  • BeforeEach 中建立一個包含 3 個項目的 Order
  • 測試案例 testGetPostageLabel_threeItems_largePackage 依賴此 Order 恰好有 3 個項目
  • 後來有工程師為了測試新的「hazardous」功能,在共享配置中加入第 4 個項目
  • 原本測試 3 個項目情境的測試案例,現在實際上測試的是 4 個項目,但仍然通過

11.4.4 解法:在測試案例內定義重要配置#

當配置會直接影響測試結果時,應在測試案例內自行設置:

  • 使用 Helper 函式減少重複程式碼(例如 createOrderWithItems(items)
  • 每個測試案例只設置它所關心的特定值
  • 未來修改時不會意外破壞其他測試

11.4.5 何時適合使用共享配置#

當配置是「必要但與測試結果無關」時,使用共享配置是合理的:

  • 例如 PostageManager 不使用 OrderMetadata,但建構 Order 時必須提供
  • OrderMetadata 放在共享常數中,避免每個測試案例重複建構這些無關的資料

如果測試需要配置大量「必要但無關」的值,這可能代表函式參數不夠精準。例如 getPostageLabel() 或許只需要 AddressList<Item>,而非完整的 Order 物件。


11.5 使用適當的斷言匹配器#

斷言匹配器(Assertion Matcher)決定測試是否通過,也決定失敗訊息的品質。選擇適當的匹配器對於產出清晰的失敗訊息至關重要。

11.5.1 不適當的匹配器導致糟糕的錯誤訊息#

TextWidget.getClassNames() 為例,它回傳所有 CSS class 名稱(包含硬編碼和自訂的),且文件說明回傳順序不保證

過度約束的斷言:

assertThat(textWidget.getClassNames()).isEqualTo([
    "text-widget", "selectable", "custom_class_1", "custom_class_2"]);

問題:測試了超出範圍的行為(包含標準 class 名稱和順序)。

失敗訊息不佳的斷言:

assertThat(result.contains("custom_class_1")).isTrue();

問題:失敗時只會顯示 “The subject was false, but was expected to be true”,完全無法說明實際問題。

Figure 11.6: An inappropriate assertion matcher can result in a poorly explained test failure.

11.5.2 解法:使用適當的匹配器#

使用語意精確的匹配器,例如 containsAtLeast()

assertThat(textWidget.getClassNames())
    .containsAtLeast("custom_class_1", "custom_class_2");

好處:

  • 只測試我們關心的行為(包含自訂 class 名稱)
  • 不受順序變化或標準 class 名稱更改影響
  • 失敗訊息清楚說明缺少哪些值

Figure 11.7: An appropriate assertion matcher will produce a well-explained test failure.

適當的匹配器也讓程式碼更易讀。assertThat(someList).contains("value")assertThat(someList.contains("value")).isTrue() 更像自然語言。


11.6 使用依賴注入提升可測試性#

依賴注入(Dependency Injection)讓程式碼更模組化,也更容易測試。

11.6.1 硬編碼相依性讓程式碼無法測試#

InvoiceReminder 為例,如果在建構子中自行建立 AddressBookEmailSender

  • AddressBook 連接真實客戶資料庫 – 測試環境可能無權限存取,且真實資料會隨時間變化造成不穩定
  • EmailSender 會發送真實郵件 – 測試不應造成真實世界的副作用
  • 無法在測試中替換為 Test Double,多項行為可能無法被正確測試

11.6.2 解法:使用依賴注入#

將相依性透過建構子注入:

class InvoiceReminder {
    InvoiceReminder(AddressBook addressBook, EmailSender emailSender) {
        this.addressBook = addressBook;
        this.emailSender = emailSender;
    }

    static InvoiceReminder create() {
        return new InvoiceReminder(
            DataStore.getAddressBook(), new EmailSenderImpl());
    }
}
  • 測試可以輕鬆使用 FakeAddressBookFakeEmailSender
  • 靜態工廠方法(static factory function)讓正式程式碼的使用者不需擔心相依性

可測試性與模組化高度相關。當不同程式碼之間鬆散耦合且可重新配置時,測試就容易得多。依賴注入是實現這兩個目標的有效技巧。


11.7 測試的更廣泛視野#

單元測試是工程師日常最常接觸的測試層級,但絕非唯一。其他重要的測試層級包括:

  • 整合測試(Integration Tests) – 確保系統中多個元件或子系統之間的整合正常運作
  • 端對端測試(End-to-End Tests) – 測試從頭到尾的完整使用者流程

其他值得了解的測試類型:

  • 回歸測試(Regression Testing) – 定期執行以確保軟體行為未發生不預期的改變
  • Golden Testing(或稱 Characterization Testing)– 基於輸出快照比對,有助於偵測變化但失敗原因可能不明確
  • 模糊測試(Fuzz Testing) – 以大量隨機或「有趣的」輸入呼叫程式碼,檢查是否會導致當機

撰寫與維護高品質的軟體通常需要混合使用多種測試技巧。單元測試很可能無法單獨滿足所有測試需求。


11.8 本章總結#

  • 測試行為,而非函式 – 只為每個函式寫一個測試很容易導致測試不足。應識別所有重要行為並為每個行為撰寫測試案例
  • 測試真正重要的行為 – 測試私有函式幾乎總是代表我們沒有測試真正重要的事情
  • 一次測試一個行為 – 結果是測試更容易理解,且失敗訊息更清晰
  • 謹慎使用共享測試設置 – 它能避免重複程式碼,但不當使用可能導致測試失效或不穩定
  • 使用依賴注入 – 能顯著提升程式碼的可測試性
  • 善用多種測試層級 – 單元測試是最常見的,但撰寫高品質軟體往往需要搭配多種測試技巧