Unit Testing#

本章由 Erik Kuefler 撰寫、Tom Manshreck 編輯,探討單元測試(unit testing)的可維護性與最佳實踐。前一章介紹了 Google 對測試的兩大分類軸線——大小(size)範疇(scope)。單元測試指的是範疇相對狹窄的測試,例如針對單一類別或方法的測試,通常屬於小型測試。

單元測試之所以是提升工程師生產力的利器,原因包括:

  • 快速且確定性高:小型測試讓開發者能頻繁執行並獲得即時回饋
  • 容易與產品程式碼同步撰寫:工程師可專注於當前正在開發的部分
  • 促進高測試覆蓋率:撰寫門檻低,使工程師能更有信心地進行變更
  • 失敗時容易定位問題:每個測試概念簡單,聚焦在系統的特定部分
  • 兼具文件功能:展示系統的使用方式與預期行為

Google 的經驗法則建議測試組合大約為 80% 單元測試、20% 範疇更廣的測試。工程師在日常工作中執行數千個單元測試是稀鬆平常的事。

正因為單元測試佔據工程師日常的大部分,Google 對測試的可維護性(maintainability) 投入了大量關注。可維護的測試是那些「就是能用」的測試:寫完後工程師不需再想到它們,直到它們失敗——而失敗時能指出真正的 bug 及清楚的原因。本章的重點即在探討可維護性的概念與實現技巧。

可維護性的重要性#

想像一個情境:Mary 只需新增幾十行程式碼就能實作一個簡單的新功能,但提交變更時,自動化測試系統卻回傳一整頁錯誤。她花了一整天逐一檢視這些失敗——每個案例都不是真正的 bug,而是測試對內部結構做了不當假設,導致需要更新。更糟的是,她難以理解這些測試原本的用意,修補的 hack 又讓測試更加晦澀。

本應是快速完成的工作,卻花了數小時甚至數天在繁瑣的測試維護上,嚴重打擊 Mary 的生產力和士氣。

這個場景說明了測試若維護不當,反而會拖累生產力而非提升它,同時並未真正提高受測程式碼的品質。這個情境在 Google 內部並不罕見,許多工程師每天都在與類似問題搏鬥。雖然沒有靈丹妙藥,但 Google 工程師已發展出一套模式與實踐來緩解這些問題。

Mary 遇到的問題並非她的過失,她也無法避免——糟糕的測試必須在簽入前修復,否則會對後續工程師造成拖累。廣義來說,她遇到的問題可歸納為兩大類:

  1. 脆弱的測試(brittle tests):對無害且無關的變更產生誤報
  2. 不清晰的測試(unclear tests):失敗後難以判斷問題所在與修復方式

避免脆弱的測試#

脆弱的測試是指在產品程式碼發生無關變更且未引入真正 bug 時仍然失敗的測試。在小型程式庫中,每次變更都需微調幾個測試或許還能接受;但隨著程式庫成長,測試維護將消耗越來越多的團隊時間。在 Google 的規模下,一次大規模變更(large-scale change)可能觸發數十萬個測試,即使只有很小比例的測試出現假性失敗,也會浪費大量工程時間。

追求不變的測試#

在討論避免脆弱測試的模式之前,我們需要回答一個問題:測試寫完後,預期多久需要修改一次?理想的測試是不變的(unchanging)——寫完後除非系統需求改變,否則不需要再動它。

產品程式碼的變更可分為四種類型,測試應有不同的預期回應:

  1. 純重構(pure refactoring):不改變介面的內部重構,測試不應需要變更。若需變更,表示測試的抽象層次不恰當
  2. 新功能(new features):新增功能時,既有測試不應需要變更,只需撰寫新測試覆蓋新行為
  3. 修復 bug(bug fixes):與新功能類似,通常只需補上缺失的測試案例,不應更動既有測試
  4. 行為變更(behavior changes):這是唯一預期需要修改既有測試的情況,因為它破壞了系統的明確契約。此類變更的成本通常遠高於前三種

核心要點:寫完測試後,重構系統、修 bug、新增功能都不應需要回頭修改測試。只有系統行為的破壞性變更才需要。這正是讓系統能在大規模下運作的關鍵。

透過公開 API 測試#

確保測試不會因實作細節變更而失敗的最重要做法,就是以使用者的方式呼叫系統——透過公開 API(public API)而非內部實作來撰寫測試。

以一個交易處理器為例,它有 processTransactionsetAccountBalancegetAccountBalance 等公開方法,以及 isValidsaveToDatabase 等私有方法。

反面範例——直接測試私有方法:

@Test
public void emptyAccountShouldNotBeValid() {
  assertThat(processor.isValid(newTransaction().setSender(EMPTY_ACCOUNT)))
      .isFalse();
}

@Test
public void shouldSaveSerializedData() {
  processor.saveToDatabase(newTransaction()
      .setId(123)
      .setSender("me")
      .setRecipient("you")
      .setAmount(100));
  assertThat(database.get(123)).isEqualTo("me,you,100");
}

這種測試窺探了系統的內部狀態並呼叫非公開的方法。一旦重新命名方法、抽取輔助類別或改變序列化格式,測試就會壞掉——即使這些變更對類別的真實使用者完全不可見。

正面範例——只透過公開 API 測試:

@Test
public void shouldTransferFunds() {
  processor.setAccountBalance("me", 150);
  processor.setAccountBalance("you", 20);

  processor.processTransaction(newTransaction()
      .setSender("me")
      .setRecipient("you")
      .setAmount(100));

  assertThat(processor.getAccountBalance("me")).isEqualTo(50);
  assertThat(processor.getAccountBalance("you")).isEqualTo(120);
}

透過公開 API 的測試以使用者相同的方式存取受測系統,形成明確的契約:若這樣的測試失敗,意味著既有使用者也會受到影響。你可以自由地對系統進行任何內部重構,而不需要費力地修改測試。

「公開 API」的定義並非總是顯而易見。以下是判斷的經驗法則:

  • 輔助類別(helper class):如果一個類別只為支援一兩個其他類別而存在,應透過那些類別來測試,而非直接測試
  • 公開可存取的套件或類別:任何人都能使用且無需諮詢擁有者的,應視為獨立單元直接測試
  • 支援函式庫(support library):雖然只有擁有者能存取,但設計為通用功能的,也應視為獨立單元直接測試。測試上的些許冗餘是值得的,因為它能防止覆蓋率缺口

測試狀態,而非互動#

驗證系統行為有兩種方式:

  • 狀態測試(state testing):觀察系統呼叫後的狀態
  • 互動測試(interaction testing):檢查系統是否對協作者執行了預期的動作序列

互動測試往往比狀態測試更脆弱,原因與測試私有方法比測試公開方法更脆弱的道理相同——互動測試檢查的是系統如何達到結果,而非結果是什麼

反面範例(脆弱的互動測試):

@Test
public void shouldWriteToDatabase() {
  accounts.createUser("foobar");
  verify(database).put("foobar");
}

此測試有兩個問題:若資料寫入後隨即被刪除,測試仍會通過;若系統重構為呼叫不同但等效的 API,測試會失敗。

正面範例(狀態測試):

@Test
public void shouldCreateUsers() {
  accounts.createUser("foobar");
  assertThat(accounts.getUser("foobar")).isNotNull();
}

問題性互動測試最常見的成因是過度依賴 mocking 框架。這些框架使錄製和驗證每次呼叫變得容易,但直接導向脆弱的互動測試。Google 傾向在物件快速且具確定性的前提下,優先使用真實物件而非 mock 物件

撰寫清晰的測試#

即使完全避免了脆弱性,測試終究會失敗。測試失敗只有兩種原因:

  1. 受測系統有問題或不完整——這正是測試的設計目的
  2. 測試本身有缺陷——測試規格錯誤,屬於脆弱測試的範疇

工程師首要任務是判斷失敗屬於哪一種,而判斷的速度取決於測試的清晰度(clarity)。清晰的測試讓人一眼就能看出它存在的目的以及失敗的原因。

不清晰的測試隨時間會成為沉重負擔。測試往往比撰寫者的任期更長久,而不清晰的測試可能在無人能理解其用途的情況下,最終被刪除——不僅產生覆蓋率缺口,更意味著該測試自始至終沒有提供任何價值。

讓測試完整且簡潔#

幫助測試達到清晰的兩個高層次屬性:

  • 完整性(completeness):測試主體包含讀者理解結果所需的全部資訊
  • 簡潔性(conciseness):測試不包含任何令人分心或不相關的資訊

反面範例:將大量無關參數傳入建構子,而重要的測試輸入卻藏在輔助方法中。

正面範例:使用輔助方法隱藏不相關的建構細節,同時在測試主體中明確展示關鍵輸入。

@Test
public void shouldPerformAddition() {
  Calculator calculator = newCalculator();
  int result = calculator.calculate(newCalculation(2, Operation.PLUS, 3));
  assertThat(result).isEqualTo(5);
}

在測試中,為了清晰度而違反 DRY(Don’t Repeat Yourself)原則往往是值得的。測試主體應包含理解它所需的一切資訊,同時不包含任何無關的干擾。

測試行為,而非方法#

許多工程師的直覺是讓測試結構對應程式碼結構——每個產品方法對應一個測試方法。但隨著方法複雜度增長,測試也會變得難以理解。

更好的做法是為每個行為(behavior)撰寫測試,而非為每個方法。行為是系統對一系列輸入在特定狀態下如何回應的保證,通常可以用 “Given…When…Then” 來表達:

「給定(Given)一個空的銀行帳戶,當(When)嘗試提款時,則(Then)交易被拒絕。」

方法與行為之間是多對多的關係:大多數非簡單方法實作多個行為,某些行為依賴多個方法的互動。

行為驅動的測試更清晰,原因在於:

  • 讀起來更像自然語言,便於直覺理解
  • 因果關係更明確,因為每個測試的範疇更有限
  • 每個測試簡短且具描述性,便於確認哪些功能已被測試,也鼓勵工程師新增精簡的測試方法而非堆疊到既有方法上

強調行為的測試結構#

每個行為都有三個部分:Given(系統的設置方式)、When(對系統採取的動作)、Then(驗證結果)。有些框架如 Cucumber 和 Spock 直接內建此結構,其他語言可透過空白行和註解來凸顯。

@Test
public void transferFundsShouldMoveMoneyBetweenAccounts() {
  // Given two accounts with initial balances of $150 and $20
  Account account1 = newAccountWithBalance(usd(150));
  Account account2 = newAccountWithBalance(usd(20));

  // When transferring $100 from the first to the second account
  bank.transferFunds(account1, account2, usd(100));

  // Then the new account balances should reflect the transfer
  assertThat(account1.getBalance()).isEqualTo(usd(50));
  assertThat(account2.getBalance()).isEqualTo(usd(120));
}

這種模式允許讀者在三個層次上閱讀測試:

  1. 先看測試方法名稱,取得行為的粗略描述
  2. 若不夠,看 Given/When/Then 註解取得正式描述
  3. 最後看實際程式碼,取得行為的精確表達

最常違反此模式的做法是在多次呼叫受測系統之間穿插斷言(即混合 “when” 和 “then” 區塊),這會讓讀者難以區分「正在執行的動作」與「預期的結果」。

當測試需要驗證多步驟流程時,可以定義交替的 when/then 區塊,並使用 “and” 來拆分較長的區塊:

@Test
public void shouldTimeOutConnections() {
  // Given two users
  User user1 = newUser();
  User user2 = newUser();

  // And an empty connection pool with a 10-minute timeout
  Pool pool = newPool(Duration.minutes(10));

  // When connecting both users to the pool
  pool.connect(user1);
  pool.connect(user2);

  // Then the pool should have two connections
  assertThat(pool.getConnections()).hasSize(2);

  // When waiting for 20 minutes
  clock.advance(Duration.minutes(20));

  // Then the pool should have no connections
  assertThat(pool.getConnections()).isEmpty();

  // And each user should be disconnected
  assertThat(user1.isConnected()).isFalse();
  assertThat(user2.isConnected()).isFalse();
}

每個測試應只覆蓋一個行為,絕大多數單元測試只需要一個 “when” 和一個 “then” 區塊。若需在測試名稱中使用 “and”,很可能是在測試多個行為,應拆分為多個測試。

以行為命名測試#

測試名稱應摘要描述其測試的行為,包含對系統採取的動作預期結果。一個有用的技巧是以 “should” 開頭,搭配受測類別名稱形成一個完整的句子。例如:

  • BankAccount 類別的測試 shouldNotAllowWithdrawalsWhenBalanceIsEmpty 可讀為「BankAccount should not allow withdrawals when balance is empty」

命名策略範例:

multiplyingTwoPositiveNumbersShouldReturnAPositiveNumber
multiply_positiveAndNegative_returnsNegative
divide_byZero_throwsException

不要在測試中放入邏輯#

清晰的測試應在目視檢查下就能確認正確性。測試程式碼只需處理特定的輸入組合,不像產品程式碼需要通用化。當測試包含運算子、迴圈、條件判斷等邏輯,就需要心算才能確認結果。

// 含有邏輯——隱藏 bug 的測試
@Test
public void shouldNavigateToAlbumsPage() {
  String baseUrl = "http://photos.google.com/";
  Navigator nav = new Navigator(baseUrl);
  nav.goToAlbumPage();
  assertThat(nav.getCurrentUrl()).isEqualTo(baseUrl + "/albums");
}

移除字串串接邏輯後,bug 立即現形:期望的 URL 會包含兩個斜線(http://photos.google.com//albums)。

原則:在測試程式碼中,堅持直線式程式碼(straight-line code),避免巧妙的邏輯,並容忍些許重複以換取描述性與意義。

撰寫清晰的失敗訊息#

理想狀態下,工程師只需閱讀失敗訊息就能診斷問題,不需要查看測試程式碼本身。好的失敗訊息應清楚表達:預期結果實際結果以及相關參數

不良範例Test failed: account is closed(無法判斷帳戶關閉是預期還是實際狀態)

良好範例Expected an account in state CLOSED, but got account: <{name: "my-account", state: "OPEN"}>

像 Google 開發的 Truth 函式庫這類工具,能自動產生更有用的錯誤訊息。例如 assertThat(colors).contains("orange") 失敗時會產生:<[red, green, blue]> should have contained <orange>,遠比 assertTrue 的泛用 expected <true> but was <false> 更具參考價值。

測試中的程式碼共享:DAMP 優於 DRY#

大多數軟體追求 DRY(Don’t Repeat Yourself) 原則——每個概念在一處有正規表達,程式碼重複降至最低。但在測試程式碼中,此成本效益分析有所不同:

  • DRY 的好處在測試中較少:好的測試設計上就該是穩定的,不像產品程式碼需要頻繁變更
  • 複雜性的代價在測試中更大:產品程式碼有測試套件確保其在複雜化後仍然運作,但測試本身必須自證正確

測試程式碼應追求 DAMP(Descriptive And Meaningful Phrases)——在測試中適度的重複是可以接受的,只要它讓測試更簡單、更清晰。

以下是一個過度 DRY 的反面範例——重要細節被隱藏在輔助方法中,測試主體雖簡潔但不完整:

@Test
public void shouldAllowMultipleUsers() {
  List<User> users = createUsers(false, false);
  Forum forum = createForumAndRegisterUsers(users);
  validateForumAndUsers(forum, users);
}

改寫為 DAMP 風格後,雖然有更多重複,但每個測試都能完全獨立理解:

@Test
public void shouldAllowMultipleUsers() {
  User user1 = newUser().setState(State.NORMAL).build();
  User user2 = newUser().setState(State.NORMAL).build();

  Forum forum = new Forum();
  forum.register(user1);
  forum.register(user2);

  assertThat(forum.hasRegisteredUser(user1)).isTrue();
  assertThat(forum.hasRegisteredUser(user2)).isTrue();
}

DAMP 並非 DRY 的替代品,而是互補的。輔助方法和測試基礎設施仍然可以讓測試更清晰,前提是重構的目標是讓測試更具描述性和意義,而非單純減少重複。

共享值#

許多測試透過定義共享常數來結構化,但這會導致問題——讀者需要捲動到檔案其他部分才能理解某個值為何被選用。即使使用描述性名稱(如 CLOSED_ACCOUNT),仍然難以直接看到被測試值的確切細節。

更好的方式是使用輔助方法(helper methods) 建構資料,讓測試作者只需指定關心的欄位,其餘使用合理的預設值:

def newContact(
    firstName="Grace", lastName="Hopper", phoneNumber="555-123-4567"):
  return Contact(firstName, lastName, phoneNumber)

def test_fullNameShouldCombineFirstAndLastNames(self):
  contact = newContact(firstName="Ada", lastName="Lovelace")
  self.assertEqual(contact.fullName(), "Ada Lovelace")

共享設置#

測試框架通常允許定義在每個測試前執行的 setup 方法。最佳用途是建構受測物件及其協作者。但若測試開始依賴 setup 中的特定值,就會損害測試的完整性。

反面範例:在 setUp 方法中設定 "Donald Knuth",然後在幾百行後的測試中直接斷言此值——讀者必須到處尋找這個字串的來源。

正面範例:在測試方法中明確覆寫 setup 設定的值,使測試自包含:

@Test
public void shouldReturnNameFromService() {
  nameService.set("user1", "Margaret Hamilton");
  UserDetails user = userStore.get("user1");
  assertThat(user.getName()).isEqualTo("Margaret Hamilton");
}

雖然多了些重複,但結果更具描述性,讀者不必離開測試主體就能完全理解測試的意圖。

共享輔助方法與驗證#

輔助方法對於簡潔地建構測試值很有用,但其他類型的輔助方法需要謹慎。尤其要避免在每個測試最後都呼叫一個通用的 validate 方法——這會讓測試偏離行為驅動的風格,難以判斷特定測試的意圖。

較佳的驗證輔助方法應只斷言關於輸入的單一概念性事實,特別是當驗證條件概念簡單但實作上需要迴圈或條件邏輯時。例如:

private void assertUserHasAccessToAccount(User user, Account account) {
  for (long userId : account.getUsersWithAccess()) {
    if (user.getId() == userId) {
      return;
    }
  }
  fail(user.getName() + " cannot access " + account.getName());
}

這個輔助方法封裝了一個概念簡單但實作上需要迴圈的驗證邏輯,使測試主體保持清晰。

定義測試基礎設施#

跨多個測試套件共享的程式碼稱為測試基礎設施(test infrastructure)。雖然它在整合測試或端對端測試中更常見,但精心設計的測試基礎設施在某些情境下也能讓單元測試更容易撰寫。

自訂測試基礎設施需要比單一測試套件內的程式碼共享更加謹慎。在許多方面,測試基礎設施更類似於產品程式碼——有許多呼叫者依賴它,且難以在不造成破壞的情況下變更。大多數工程師在測試自己的功能時,並不會去修改公用的測試基礎設施。因此,測試基礎設施需要被視為一個獨立的產品,相應地,測試基礎設施必須有自己的測試

當然,大部分工程師使用的測試基礎設施來自廣為人知的第三方函式庫,如 JUnit。組織內應盡早且盡可能全面地標準化這些函式庫。

Google 多年前就規定 Mockito 為 Java 測試唯一使用的 mocking 框架,禁止新測試使用其他框架。這項決策在當時引發了一些不滿,但如今被普遍視為正確的決定——它讓測試更容易理解和維護。

結論#

單元測試是確保系統在面對意料之外的變更時持續運作的最強大工具之一。但如果使用不當,可能導致系統需要更多的維護成本和更大的變更阻力,卻未能真正提升信心。Google 的單元測試遠非完美,但遵循本章實踐的測試比不遵循的高出數個量級的價值

TL;DRs#

  • 追求不變的測試(unchanging tests)
  • 透過公開 API 測試
  • 測試狀態,而非互動
  • 讓測試完整且簡潔
  • 測試行為,而非方法
  • 以強調行為的方式結構化測試
  • 以被測試的行為命名測試
  • 不要在測試中放入邏輯
  • 撰寫清晰的失敗訊息
  • 測試程式碼共享遵循 DAMP 優於 DRY