單元測試實務#
本章建立在第 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() 改為公開,會產生三個問題:
- 測試的不是我們真正關心的行為 – 測試的是
isEligibleForMortgage()回傳false,而不是房貸申請最終被拒絕。有人可能修改assess()使其不再呼叫此函式,測試仍會通過 - 測試與實作細節緊耦合 – 重新命名函式或將其移到輔助類別就會導致測試失敗
- 改變了類別的公開 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 的
TestCaseattribute、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 實例並在所有測試案例間共享:
- 第一個測試案例將訂單標記為「延遲」存入資料庫
- 第二個測試案例即使程式碼有 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()或許只需要Address和List<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 為例,如果在建構子中自行建立 AddressBook 和 EmailSender:
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());
}
}- 測試可以輕鬆使用
FakeAddressBook和FakeEmailSender - 靜態工廠方法(static factory function)讓正式程式碼的使用者不需擔心相依性
可測試性與模組化高度相關。當不同程式碼之間鬆散耦合且可重新配置時,測試就容易得多。依賴注入是實現這兩個目標的有效技巧。
11.7 測試的更廣泛視野#
單元測試是工程師日常最常接觸的測試層級,但絕非唯一。其他重要的測試層級包括:
- 整合測試(Integration Tests) – 確保系統中多個元件或子系統之間的整合正常運作
- 端對端測試(End-to-End Tests) – 測試從頭到尾的完整使用者流程
其他值得了解的測試類型:
- 回歸測試(Regression Testing) – 定期執行以確保軟體行為未發生不預期的改變
- Golden Testing(或稱 Characterization Testing)– 基於輸出快照比對,有助於偵測變化但失敗原因可能不明確
- 模糊測試(Fuzz Testing) – 以大量隨機或「有趣的」輸入呼叫程式碼,檢查是否會導致當機
撰寫與維護高品質的軟體通常需要混合使用多種測試技巧。單元測試很可能無法單獨滿足所有測試需求。
11.8 本章總結#
- 測試行為,而非函式 – 只為每個函式寫一個測試很容易導致測試不足。應識別所有重要行為並為每個行為撰寫測試案例
- 測試真正重要的行為 – 測試私有函式幾乎總是代表我們沒有測試真正重要的事情
- 一次測試一個行為 – 結果是測試更容易理解,且失敗訊息更清晰
- 謹慎使用共享測試設置 – 它能避免重複程式碼,但不當使用可能導致測試失效或不穩定
- 使用依賴注入 – 能顯著提升程式碼的可測試性
- 善用多種測試層級 – 單元測試是最常見的,但撰寫高品質軟體往往需要搭配多種測試技巧