單元測試原則#

每次工程師修改一行程式碼,都有可能無意間破壞某些功能。即使是看起來微不足道的變更,也可能造成嚴重後果——「這只是改了一行而已」往往是系統崩潰前的名言。正因為每次變更都有風險,我們需要一種方式來確保程式碼正確運作,而測試正是提供這種保障的主要手段。

單元測試(unit testing)關注的是以相對隔離的方式測試獨立的程式碼單元。「單元」通常指一個特定的 class、function 或檔案。不需要過度糾結於單元測試的精確定義,真正重要的是:確保程式碼被充分測試,而且測試本身是可維護的。

10.1 單元測試基礎#

在單元測試中,有幾個重要的概念與術語:

  • Code under test(待測程式碼):我們嘗試測試的「真實程式碼」
  • Test code(測試程式碼):用來測試真實程式碼的程式碼,通常放在獨立檔案中(例如 GuestList.lang 對應 GuestListTest.lang
  • Test case(測試案例):每個測試案例測試一個特定的行為或情境,通常是一個 function
  • Test runner(測試執行器):實際執行測試的工具,會輸出每個測試案例的通過或失敗結果

Arrange-Act-Assert 模式#

對於非最簡單的測試案例,通常將程式碼分為三個區塊:

  1. Arrange(準備):設定測試值、配置 dependency、建構待測物件的實例
  2. Act(執行):呼叫待測程式碼的 function,觸發要測試的行為
  3. 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 APIlookupEmailAddress(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