單元測試原則#
每次工程師修改一行程式碼,都有可能無意間破壞某些功能。即使是看起來微不足道的變更,也可能造成嚴重後果——「這只是改了一行而已」往往是系統崩潰前的名言。正因為每次變更都有風險,我們需要一種方式來確保程式碼正確運作,而測試正是提供這種保障的主要手段。
單元測試(unit testing)關注的是以相對隔離的方式測試獨立的程式碼單元。「單元」通常指一個特定的 class、function 或檔案。不需要過度糾結於單元測試的精確定義,真正重要的是:確保程式碼被充分測試,而且測試本身是可維護的。
10.1 單元測試基礎#
在單元測試中,有幾個重要的概念與術語:
- Code under test(待測程式碼):我們嘗試測試的「真實程式碼」
- Test code(測試程式碼):用來測試真實程式碼的程式碼,通常放在獨立檔案中(例如
GuestList.lang對應GuestListTest.lang) - Test case(測試案例):每個測試案例測試一個特定的行為或情境,通常是一個 function
- Test runner(測試執行器):實際執行測試的工具,會輸出每個測試案例的通過或失敗結果
Arrange-Act-Assert 模式#
對於非最簡單的測試案例,通常將程式碼分為三個區塊:
- Arrange(準備):設定測試值、配置 dependency、建構待測物件的實例
- Act(執行):呼叫待測程式碼的 function,觸發要測試的行為
- Assert(斷言):檢查回傳值或結果狀態是否符合預期
有些工程師偏好使用 Given-When-Then 來取代 Arrange-Act-Assert。在測試案例的程式碼層面,兩者是等價的。

Figure 10.1: How various unit testing concepts fit together.
在大多數專業軟體工程環境中,幾乎每一段「真實程式碼」都應該有對應的單元測試,而每一個行為都應該有對應的測試案例。
10.2 好的單元測試具備哪些特性?#
好的單元測試應具備以下五個關鍵特性:
| # | 特性 | 說明 |
|---|---|---|
| 1 | 準確偵測破壞(Accurately detects breakages) | 程式碼壞了,測試就該失敗;程式碼沒壞,測試就不該失敗 |
| 2 | 不受實作細節影響(Agnostic to implementation details) | 實作細節的變更不應導致測試需要修改 |
| 3 | 失敗訊息清楚明瞭(Well-explained failures) | 測試失敗時應清楚說明問題所在 |
| 4 | 測試程式碼易於理解(Understandable test code) | 其他工程師能理解測試在做什麼 |
| 5 | 容易且快速執行(Easy and quick to run) | 工程師日常需要頻繁執行測試 |
mindmap root((好的單元測試)) 準確偵測破壞 不受實作細節影響 失敗訊息清楚 測試程式碼易懂 容易且快速執行
10.2.1 準確偵測破壞#
測試的最主要目的是確保程式碼沒有問題。這有兩個重要的作用:
- 提供初始信心:無論多小心寫程式,都難免犯錯。撰寫完善的測試能在程式碼提交前發現並修正大部分錯誤
- 防範未來的回歸(regression):在多人協作的 codebase 中,其他工程師的變更可能無意間破壞我們的程式碼。測試是防止這種情況的最有效防線
同樣重要的是:測試應該只在程式碼真正出問題時才失敗。
Flaky test(不穩定測試)指的是在待測程式碼沒有問題的情況下,測試時而通過、時而失敗。這通常由非確定性行為造成(如隨機性、timing race condition、依賴外部系統)。Flaky test 的危害遠超表面——就像「狼來了」的寓言,當工程師習慣忽略誤報的測試失敗後,真正的問題也會被忽略。
10.2.2 不受實作細節影響#
工程師對 codebase 的變更大致分為兩類:
- 功能性變更(Functional change):修改外部可見的行為,例如新增功能、修復 bug
- 重構(Refactoring):結構性變更,例如拆分大 function、搬移工具程式碼。正確的重構不應改變任何外部可見行為
考慮兩種測試方式:
| 方式 A:鎖定實作細節 | 方式 B:只鎖定行為 | |
|---|---|---|
| 做法 | 測試 private function、直接操作 private 變數和 dependency | 只透過 public API 設定狀態和驗證行為 |
| 重構後果 | 無論重構是否正確,測試都會失敗,需要大量修改 | 若重構正確,測試繼續通過;若測試失敗,代表重構有誤 |
| 信心程度 | 難以判斷哪些失敗是預期的、哪些是真的問題 | 非常容易判斷:通過就沒問題,失敗就是出錯了 |
不要同時進行功能性變更和重構。 重構不應改變行為,功能性變更應該改變行為。如果兩者同時進行,很難區分哪些行為變化是預期的、哪些是重構造成的錯誤。先做重構,再做功能性變更,這樣更容易隔離潛在問題。
10.2.3 失敗訊息清楚明瞭#
當測試失敗時,其他工程師需要藉由失敗訊息來了解哪裡出了問題。如果訊息含糊不清,將浪費大量時間。

Figure 10.2: A test failure that clearly explains what is wrong is a lot more useful than one that just indicates that something is wrong.
確保測試失敗訊息清楚的最佳做法:
- 一次測試一件事:每個測試案例鎖定一個特定行為
- 使用描述性的名稱:測試案例名稱應描述所測試的行為或情境
- 這通常意味著要有多個小型測試案例,而非一個大型的「測試所有東西」案例
10.2.4 測試程式碼易於理解#
測試失敗代表「程式碼的行為發生了變化」,但這不一定代表程式碼壞了——變更可能是故意的。工程師需要理解測試才能安全地進行修改:
- 知道每個測試案例在測什麼
- 知道測試是怎麼做的
- 避免一次測試太多東西或過度使用共用的 test setup
此外,有些工程師會把測試當作程式碼的使用說明書——閱讀測試來了解功能和用法。
10.2.5 容易且快速執行#
- 許多 codebase 會在提交前執行 presubmit check,慢測試會拖慢所有工程師
- 工程師在開發過程中經常多次執行測試,慢測試會嚴重影響效率
- 當測試執行變得痛苦時,工程師傾向少測試——讓測試快速、簡單,不只提高效率,也能促進更廣泛的測試
10.3 聚焦 Public API,但不忽略重要行為#
既然測試應不受實作細節影響,而程式碼可分為 public API 和 implementation details,那麼我們應該盡量透過 public API 來測試。
以計算動能的 function 為例:
Double calculateKineticEnergyJ(Double massKg, Double speedMs) {
return 0.5 * massKg * Math.pow(speedMs, 2.0);
}呼叫者關心的是「給定質量和速度,回傳正確的動能值」。至於函式內部使用 Math.pow() 還是 speedMs * speedMs,是實作細節。專注於 public API 的測試只會驗證回傳值是否正確:
void testCalculateKineticEnergy_correctValueReturned() {
assertThat(calculateKineticEnergyJ(3.0, 7.0))
.isWithin(1.0e-10)
.of(73.5);
}10.3.1 重要行為可能在 Public API 之外#
現實中的程式碼很少是完全自包含的,通常依賴許多其他元件。以咖啡販賣機為例說明:

Figure 10.3: A coffee vending machine has a public API, but we can't fully test the machine using only the public API.
販賣機的 public API(顧客角度)很簡單:刷卡、選飲料、取走飲品。但作為測試者,我們需要考慮更多:
- 設定 dependency:插電、裝水、放咖啡豆——對顧客是實作細節,但測試不可能跳過這些
- 驗證 side effect:這台「智慧」販賣機在水或豆子快用完時會自動通知技術人員——顧客不知道也不在意,但這是需要測試的重要行為
- 真正的實作細節:用 thermoblock 還是 boiler 加熱水——這才是不該測試的,因為使用者真正在意的是咖啡的口感

Figure 10.4: Tests should aim to test things using the public API whenever possible, but it can often be necessary for tests to interact with dependencies.
10.3.2 測試與 Dependency 的互動#
以 AddressBook class 為例:它透過 server 查詢使用者的 email 地址,並快取結果以避免重複請求。
- Public API:
lookupEmailAddress(userId)回傳 email 地址 - Implementation details:依賴
ServerEndPoint、使用快取
但以下行為雖然不屬於 public API,卻非常重要且需要測試:
- 重複呼叫同一 userId 的
lookupEmailAddress()不應重複呼叫 server - 我們測試的是「不會發送重複請求」這個行為,而非「使用了快取」這個實作細節

Figure 10.5: We can't fully test all the important behaviors of the AddressBook class using what we defined as the public API.
需要超出 public API 範圍來測試的常見情境:
- 與 server 互動的程式碼:可能需要設定或模擬 server 作為輸入,也可能需要驗證呼叫頻率和請求格式等 side effect
- 讀寫 database 的程式碼:可能需要在 database 中準備不同的值來觸發各種行為,也需要驗證寫入的值
「只透過 public API 測試」和「不要測試實作細節」都是極好的指導原則,但需要理解 public API 和 implementation details 的定義可能因情境而異。最重要的是確保所有重要行為都被測試到,只在真的沒有其他選擇時才偏離 public API。
10.4 Test Doubles(測試替身)#
當無法或不適合在測試中使用真實的 dependency 時,可以使用 test double——一個模擬 dependency 的物件,使其更適合在測試中使用。
10.4.1 使用 Test Double 的三個理由#
簡化測試(Simplifying a test)
有些 dependency 需要大量配置或有許多 sub-dependency,導致測試變得複雜且緊密耦合於實作細節。

Figure 10.6: It can sometimes be impractical to use real dependencies in tests.

Figure 10.7: A test double can simplify the test by removing the need to worry about sub-dependencies.
保護外部世界不受測試影響(Protecting the outside world from the test)
有些 dependency 會造成真實世界的 side effect。例如,測試扣款程式碼時,若使用真實的 BankAccount 實作,會從真實帳戶扣除真實的錢。

Figure 10.8: If a dependency causes real-world side effects, we'll likely want to use a test double.

Figure 10.9: A test double can protect real systems in the outside world from side effects.
即使不是扣款這麼極端的例子,使用真實依賴也可能導致:
- 使用者看到奇怪的測試資料(如商品名稱顯示「fake test item」)
- 影響監控和日誌系統(測試的錯誤請求推高錯誤率)
保護測試不受外部世界影響(Protecting the test from the outside world)
真實的 dependency 可能有非確定性行為(如從經常變動的 database 讀值、使用隨機數產生器),導致測試變得 flaky。

Figure 10.10: If a dependency behaves in an indeterministic manner it can cause tests to be flaky.

Figure 10.11: A test double can protect the test from any indeterministic behavior that a real dependency might exhibit.
10.4.2 Mock、Stub 和 Fake#
三種最常見的 test double:
Mock#
- 模擬方式:記錄哪些 function 被呼叫了、傳了什麼參數,本身不提供實際功能
- 用途:驗證待測程式碼是否對 dependency 造成了預期的 side effect
- 範例:使用
createMock(BankAccount)建立 mock,然後用verifyThat(mockAccount.debit).wasCalledOnce().withArguments(invoiceBalance)來驗證
Stub#
- 模擬方式:在 function 被呼叫時回傳預先定義的值
- 用途:模擬提供輸入給待測程式碼的 dependency
- 範例:
when(mockAccount.getBalance()).thenReturn(new MonetaryAmount(9.99, Currency.USD))
雖然 mock 和 stub 有明確的區別,但在日常對話中許多工程師會用 “mock” 來泛指兩者。許多測試工具也需要先建立所謂的 mock 才能使用 stub 功能。
Fake#
- 模擬方式:提供介面的替代實作,準確模擬真實 dependency 的 code contract,但實作方式簡化(例如用 member variable 儲存狀態,而非與外部系統通訊)
- 優勢:因為忠實於 code contract,能捕捉到 mock/stub 無法發現的 bug
- 維護:應由維護真實 dependency 的團隊一起維護
10.4.3 Mock 和 Stub 的問題#
Mock 和 stub 有兩個主要缺點:
可能導致不切實際的測試
工程師需要自行決定 mock/stub 的行為方式,如果對真實 dependency 的理解有誤,就會建立一個行為不正確的 mock/stub。測試會通過,但程式碼在真實環境中可能出錯。
例如:測試負數金額發票時,如果用 mock 驗證 debit() 被呼叫了負數金額,測試會通過。但真實的 BankAccount 在收到負數金額時會拋出 ArgumentException——這個 bug 被 mock 掩蓋了。
若改用 Fake,因為 FakeBankAccount 忠實地實作了 code contract(負數金額拋例外),測試會正確地失敗,讓我們立刻發現 bug。
可能造成測試與實作細節的緊密耦合
使用 mock 驗證具體呼叫了 debit() 還是 credit(),本質上是在鎖定實作細節。當工程師重構程式碼(例如改用新的 transfer() function),即使行為完全沒變,mock-based 的測試也會全部失敗。
而使用 Fake 的測試只驗證最終帳戶餘額是否正確,不在意用什麼 function 達成——這讓測試在重構時不受影響。
作者的建議:盡量使用真實的 dependency。若不可行,優先使用 Fake。Mock 和 stub 應該是最後的手段——沒有其他替代方案時才使用。
flowchart TD
Q1{"可以使用真實依賴嗎?"}
Q1 -->|Yes| R1["使用真實依賴"]
Q1 -->|No| Q2{"有 Fake 實作可用嗎?"}
Q2 -->|Yes| R2["使用 Fake"]
Q2 -->|No| R3["使用 Mock/Stub"]
R1 -.- N1["最佳"]
R2 -.- N2["次佳: 忠實於契約"]
R3 -.- N3["最後手段: 注意耦合風險"]
style R1 fill:#d4edda,stroke:#28a745
style R2 fill:#fff3cd,stroke:#ffc107
style R3 fill:#f8d7da,stroke:#dc3545
10.4.4 Mocking 的兩個學派#
| Mockist(倫敦學派) | Classicist(底特律學派) | |
|---|---|---|
| 主張 | 避免在測試中使用真實 dependency,改用 mock/stub | 盡量使用真實 dependency;不可行時用 fake;mock/stub 是最後手段 |
| 測試重點 | 測試互動(interaction):程式碼怎麼做的 | 測試結果狀態(resultant state):最終結果是什麼 |
| 優點 | 測試更加隔離;設定 mock 通常比設定真實 dependency 簡單 | 測試更貼近真實;更不受實作細節影響;重構更安全 |
| 缺點 | 可能測試通過但程式碼實際有 bug;重構困難 | 某個 dependency 的 bug 可能導致多處測試失敗 |
作者早期自然傾向 mockist 做法(因為看起來比較簡單),但後來發現這導致測試無法真正驗證功能、重構也變得困難。作者現在堅定支持 classicist 學派。
10.5 從測試哲學中擇善而從#
存在多種測試哲學與方法論,它們有時被呈現為全有或全無的選擇,但實際上我們可以自由挑選最適合的部分。
主要的測試哲學#
- Test-Driven Development(TDD):先寫測試、再寫最少量的實作程式碼使測試通過、再重構。小步迭代。也提倡保持測試隔離、聚焦、不測試實作細節
- Behavior-Driven Development(BDD):從使用者、客戶或商業角度辨識軟體應有的行為,並將其記錄下來作為測試基礎
- Acceptance Test-Driven Development(ATDD):從客戶觀點辨識行為並建立自動化驗收測試。類似 TDD,測試應在實作前建立。理論上,所有驗收測試通過即代表軟體可交付
不需要完全信奉任何單一哲學。重要的是目標——確保寫出好的、完善的測試並產出高品質的軟體。不同的人有不同的工作方式,找到最適合自己的方法就好。
10.6 本章摘要#
- 幾乎每段提交到 codebase 的「真實程式碼」都應該有對應的單元測試
- 每個行為都應該有對應的測試案例。測試案例常分為 Arrange、Act、Assert 三個區塊
- 好的單元測試的五個關鍵特性:
- 準確偵測破壞
- 不受實作細節影響
- 失敗訊息清楚明瞭
- 測試程式碼易於理解
- 容易且快速執行
- 當無法在測試中使用真實 dependency 時,可使用 test double:
- Mock:記錄呼叫行為,用於驗證 side effect
- Stub:回傳預定義值,用於模擬輸入
- Fake:提供簡化但忠實的替代實作
- Mock 和 stub 可能導致不切實際的測試,且容易與實作細節耦合
- 偏好順序:真實 dependency > Fake > Mock/Stub