Test Doubles#

單元測試是維持開發者生產力與降低缺陷的關鍵工具,但當程式碼變得複雜——例如需要向外部伺服器發送請求並將回應存入資料庫——撰寫測試就會變得困難。想像你需要撰寫數百甚至數千個這類測試,測試套件可能需要數小時才能完成,而且容易因為網路失敗或資料覆蓋而變得不穩定(flaky)。

Test double(測試替身)是一個可以在測試中替代真實實作的物件或函式,類似電影中的特技替身。Test double 的使用常被稱為 mocking,但本章避免使用該術語,因為它也指涉更具體的概念。最常見的 test double 類型是行為與真實實作相似但更簡化的版本,例如記憶體內資料庫。其他類型則可驗證系統的特定細節,如觸發罕見錯誤條件或確認某個重量級函式被呼叫但不實際執行。

由於正式環境的程式碼常需跨進程或跨機器通訊,不符合小型測試的限制,test double 提供了一個輕量替代方案,讓我們能撰寫大量快速且穩定的小型測試。

Test Double 對軟體開發的影響#

使用 test double 會帶來幾項需要權衡的複雜性:

  • 可測試性(Testability):程式碼需要被設計為可測試——測試必須能用 test double 替換真實實作。例如呼叫資料庫的程式碼需要有足夠的彈性以使用 test double 取代真實資料庫。如果程式碼在設計時未考慮測試,日後要加入測試可能需要大幅重構。

  • 適用性(Applicability):正確使用 test double 能大幅提升工程速度,但不當使用會導致脆弱、複雜且低效的測試。在大型程式碼庫中不當使用的負面影響會被放大。在許多情況下,工程師應優先使用真實實作。

  • 保真度(Fidelity):指 test double 的行為與真實實作的相似程度。如果行為差異太大,測試的價值就會很低。但完美的保真度通常不可行——test double 往往需要比真實實作簡化許多。使用 test double 的單元測試通常需要更大範圍的測試來補充驗證真實實作。

Google 的 Test Double 實踐#

Google 見證了 test double 為程式碼庫帶來的生產力與品質提升,也經歷了不當使用造成的負面影響。其實踐隨著經驗不斷演進。

一個慘痛教訓是過度使用 mocking framework 的危險。當 mocking framework 剛在 Google 被採用時,它看似萬能——能輕鬆針對孤立的程式碼撰寫高度聚焦的測試,不需擔心如何建構依賴。但數年和無數測試之後,團隊開始意識到代價:這些測試雖然容易撰寫,卻需要持續維護,而且很少發現真正的 bug。Google 的趨勢已經轉向,許多工程師開始避免 mocking framework,轉而撰寫更貼近現實的測試。

基本概念#

Test Double 範例#

想像一個需要處理信用卡付款的電商網站:

class PaymentProcessor {
  private CreditCardService creditCardService;
  ...
  boolean makePayment(CreditCard creditCard, Money amount) {
    if (creditCard.isExpired()) { return false; }
    boolean success =
        creditCardService.chargeCreditCard(creditCard, amount);
    return success;
  }
}

在測試中使用真正的信用卡服務是不可行的(想像所有交易手續費!),但可以用一個 test double 來模擬:

class TestDoubleCreditCardService implements CreditCardService {
  @Override
  public boolean chargeCreditCard(CreditCard creditCard, Money amount) {
    return true;
  }
}

雖然這個 test double 看似很簡單,它仍然能讓我們測試 makePayment() 方法中的部分邏輯,例如驗證信用卡過期時的行為。

Seam(接縫)#

程式碼如果被設計為能撰寫單元測試,就稱為具有可測試性。Seam 是使程式碼可測試的一種方式——允許在測試中使用不同的依賴來替換正式環境的依賴。

依賴注入(Dependency Injection) 是引入 seam 的常見技術。當一個類別使用依賴注入時,其所需的依賴是透過參數傳入而非直接在內部實例化:

class PaymentProcessor {
  private CreditCardService creditCardService;

  PaymentProcessor(CreditCardService creditCardService) {
    this.creditCardService = creditCardService;
  }
  ...
}

正式環境可傳入與外部伺服器通訊的真實實作,測試則可傳入 test double:

PaymentProcessor paymentProcessor =
    new PaymentProcessor(new TestDoubleCreditCardService());

Google 常使用 Guice 和 Dagger 等自動化依賴注入框架來減少手動指定建構子的樣板程式碼。在動態型別語言(如 Python 或 JavaScript)中,由於可以動態替換個別函式或方法,依賴注入的重要性較低。

撰寫可測試的程式碼需要前期投資。越早在程式碼庫的生命週期中考慮可測試性越好,因為越晚才考慮就越難套用。未考慮測試的程式碼通常需要重構或重寫才能加入適當的測試。

Mocking Framework#

Mocking framework 是一個軟體函式庫,讓測試中建立 test double 更加容易。它允許你用 mock 取代物件——mock 是一種行為在測試中以行內方式(inline)定義的 test double。使用 mocking framework 可減少樣板程式碼,因為不需要每次都定義新的類別。

class PaymentProcessorTest {
  ...
  @Mock CreditCardService mockCreditCardService;

  @Before public void setUp() {
    paymentProcessor = new PaymentProcessor(mockCreditCardService);
  }

  @Test public void chargeCreditCardFails_returnFalse() {
    when(mockCreditCardService.chargeCreditCard(any(), any()))
        .thenReturn(false);
    boolean success = paymentProcessor.makePayment(CREDIT_CARD, AMOUNT);
    assertThat(success).isFalse();
  }
}

Google 使用的 mocking framework 包括 Java 的 Mockito、C++ 的 googlemock(Googletest 的一部分)以及 Python 的 unittest.mock。雖然 mocking framework 方便好用,但過度使用往往會使程式碼庫更難維護。

三種 Test Double 技術#

Faking#

Fake 是一個 API 的輕量實作,行為與真實實作相似但不適合用於正式環境,例如記憶體內資料庫。

AuthorizationService fakeAuthorizationService =
    new FakeAuthorizationService();
AccessManager accessManager = new AccessManager(fakeAuthorizationService);

assertFalse(accessManager.userHasAccess(USER_ID));

fakeAuthorizationService.addAuthorizedUser(new User(USER_ID));
assertThat(accessManager.userHasAccess(USER_ID)).isTrue();

Fake 通常是最理想的 test double 技術,但可能不存在現成的 fake,而且撰寫和維護 fake 需要確保其行為與真實實作持續一致。

Stubbing#

Stubbing 是為一個本身沒有行為的函式指定硬編碼的回傳值。

when(mockAuthorizationService.lookupUser(USER_ID)).thenReturn(null);
assertThat(accessManager.userHasAccess(USER_ID)).isFalse();

when(mockAuthorizationService.lookupUser(USER_ID)).thenReturn(USER);
assertThat(accessManager.userHasAccess(USER_ID)).isTrue();

Stubbing 通常透過 mocking framework 完成以減少樣板程式碼。雖然快速簡單,但有其局限性。

Interaction Testing(互動測試)#

Interaction testing 驗證函式如何被呼叫,而不實際執行函式的實作。如果函式未被正確呼叫(未被呼叫、呼叫太多次或使用錯誤的參數),測試就會失敗。

AccessManager accessManager = new AccessManager(mockAuthorizationService);
accessManager.userHasAccess(USER_ID);

verify(mockAuthorizationService).lookupUser(USER_ID);

Interaction testing 有時也被稱為 mocking,但本章避免使用此術語以免與 mocking framework 混淆。Interaction testing 在特定情境下有用,但應盡量避免,因為過度使用容易產生脆弱的測試。

真實實作(Real Implementations)#

Google 在測試中的首選是使用真實實作——即正式環境中使用的相同實作。當測試執行的程式碼與正式環境一致時,測試的保真度更高。

隨著時間推移,Google 觀察到過度使用 mocking framework 會讓測試充滿重複程式碼、與真實實作脫節、並使重構困難,因此逐漸偏好真實實作。

Classical Testing vs. Mockist Testing#

  • Classical testing:偏好在測試中使用真實實作
  • Mockist testing:偏好使用 mocking framework

Google 發現 mockist testing 風格難以規模化,因為它要求工程師在設計受測系統時遵循嚴格準則,而 Google 多數工程師的預設行為更適合 classical testing。

偏好寫實而非隔離(Prefer Realism Over Isolation)#

使用真實依賴使受測系統更加寫實,因為所有真實實作中的程式碼都會在測試中被執行。相比之下,使用 test double 會將受測系統與其依賴隔離。

偏好寫實測試的原因:

  1. 更高的信心:如果單元測試過度依賴 test double,工程師可能需要額外執行整合測試或手動驗證,拖慢開發速度
  2. 實作獨立:好的測試應該基於被測試的 API 來撰寫,而非基於實作結構
  3. 更早發現 bug:使用真實實作時,如果真實實作有 bug,測試會失敗——這正是我們期望的行為

使用真實實作時,如果真實實作存在 bug 可能導致一連串測試失敗。但透過良好的開發工具(如 CI 系統),通常很容易追溯造成失敗的變更。

案例研究:@DoNotMock#

Google 觀察到太多測試過度依賴 mocking framework,因此建立了 @DoNotMock 註解(透過 ErrorProne 靜態分析工具提供)。API 擁有者可以宣告「此類型不應被 mock,因為有更好的替代方案」。

@DoNotMock("Use SimpleQuery.create() instead of mocking.")
public abstract class Query {
  public abstract String getQueryValue();
}

此註解常用於夠簡單可直接使用的 value object,以及擁有良好 fake 的 API。原因在於:每次使用 mocking framework 進行 stubbing 或 interaction testing 時,都會複製 API 提供的行為。當 API 擁有者想要修改實作時,可能會發現其 API 已在 Google 的程式碼庫中被 mock 了數千甚至數萬次,而這些 test double 很可能違反 API 契約。

何時使用真實實作#

真實實作在以下條件下是首選:快速、確定性高、依賴簡單。例如 value object(金額、日期、地址、集合類別等)應使用真實實作。

對於更複雜的程式碼,需要考量以下因素:

執行時間

  • 單元測試應該要快速,以便在開發過程中持續執行
  • 沒有絕對的標準——取決於工程師是否感受到生產力下降,以及有多少測試使用真實實作
  • 在邊界情況下,通常先使用真實實作,直到它變得太慢時再改用 test double
  • 測試的平行化也能幫助縮短執行時間

確定性(Determinism)

  • 確定性測試在給定版本下,每次執行都會產生相同結果
  • 非確定性會導致 flakiness,損害測試套件的健康
  • 常見的非確定性來源:
    • 非密封的程式碼(依賴外部服務)
    • 使用多執行緒
    • 依賴系統時鐘

對於依賴外部服務的情況,可使用 test double 或密封(hermetic)伺服器實例來避免非確定性。對於依賴系統時鐘的情況,可使用硬編碼特定時間的 test double。

依賴建構(Dependency Construction)

  • 使用真實實作需要建構所有依賴,包括整個依賴樹
  • Test double 通常沒有依賴,建構更簡單
  • 但使用 test double 來規避複雜建構有明顯的缺點
  • 理想的解決方案是使用與正式環境相同的物件建構程式碼(如 factory method 或自動化依賴注入),並使其足夠靈活以支援 test double

Faking 深入探討#

當真實實作不可行時,最佳選擇通常是使用 fake。Fake 的行為與真實實作類似,受測系統甚至不應能分辨自己是與真實實作還是 fake 互動。

public class FakeFileSystem implements FileSystem {
  private Map<String, String> files = new HashMap<>();

  @Override
  public void writeFile(String fileName, String contents) {
    files.add(fileName, contents);
  }

  @Override
  public String readFile(String fileName) {
    String contents = files.get(fileName);
    if (contents == null) { throw new FileNotFoundException(fileName); }
    return contents;
  }
}

為什麼 Fake 很重要#

Fake 執行快速且能有效測試程式碼,沒有使用真實實作的缺點。在擁有大量 fake 的軟體組織中,工程速度會顯著提升。反之,如果 fake 稀少,工程師會因使用導致緩慢和不穩定的真實實作而苦惱,或者轉而使用 stubbing 或 interaction testing,導致不清晰、脆弱且低效的測試。

何時撰寫 Fake#

  • Fake 需要更多心力和領域經驗來建立,因為必須確保行為與真實實作相似
  • Fake 需要持續維護:當真實實作行為改變時,fake 也必須更新
  • 擁有真實實作的團隊應負責撰寫和維護 fake
  • 需要權衡生產力提升是否值得建立和維護的成本
  • Fake 通常只應在測試中不可行的程式碼根部建立(例如為資料庫 API 本身建立 fake,而非為每個呼叫資料庫的類別都建立 fake)

Fake 的保真度#

Fake 最重要的概念是保真度——fake 的行為與真實實作的匹配程度。

  • Fake 應維持對 API 契約的保真度:對於任何給定的輸入,fake 應回傳相同的輸出並執行相同的狀態變更
  • 完美的保真度不一定可行,也不一定必要——fake 只需從測試的角度保持完美保真度
  • 例如,hashing API 的 fake 不需要產生完全相同的 hash 值,只要 hash 值對於給定輸入是唯一的即可
  • 延遲和資源消耗等面向的完美保真度通常對 fake 不重要
  • 如果 fake 不支援某些功能,最好讓它快速失敗(例如拋出錯誤),告知工程師此情境不適合使用該 fake

Fake 應有自己的測試#

Fake 必須有測試來確保其符合對應真實實作的 API。沒有測試的 fake,行為可能隨時間偏離真實實作。

一種方法是針對 API 的公開介面撰寫測試,並同時對真實實作和 fake 執行這些測試——這稱為契約測試(contract tests)。針對真實實作的測試可能較慢,但其缺點被最小化,因為只需由 fake 的擁有者執行。

如果沒有 Fake 可用#

  1. 先向 API 擁有者請求建立 fake
  2. 如果 API 擁有者無法或不願意建立,可以自行撰寫——將所有 API 呼叫包裝在單一類別中,然後建立該類別的 fake 版本
  3. 可以選擇使用真實實作並接受其權衡
  4. 可以退而使用其他 test double 技術並接受其權衡
  5. 可以將 fake 視為一種最佳化:如果使用真實實作的測試太慢,建立 fake 讓測試更快

Stubbing 深入探討#

Stubbing 是讓測試為函式硬編碼行為的方式,常用於快速替代真實實作:

@Test public void getTransactionCount() {
  transactionCounter = new TransactionCounter(mockCreditCardServer);
  when(mockCreditCardServer.getTransactions()).thenReturn(
      newList(TRANSACTION_1, TRANSACTION_2, TRANSACTION_3));
  assertThat(transactionCounter.getTransactionCount()).isEqualTo(3);
}

過度使用 Stubbing 的危險#

  • 測試變得不清晰:定義 stub 行為的額外程式碼會分散測試意圖,讓人需要心理推演受測系統才能理解為何要 stub 這些函式
  • 測試變得脆弱:Stubbing 將實作細節洩漏到測試中,當正式程式碼的實作細節改變時,測試也需要更新。理想的測試應該只在 API 的使用者可見行為改變時才需要修改
  • 測試變得低效:Stubbing 無法確保被 stub 的函式行為與真實實作一致。它也無法儲存狀態,使得某些場景難以測試

以下是一個過度使用 stubbing 的範例:

@Test public void creditCardIsCharged() {
  paymentProcessor =
      new PaymentProcessor(mockCreditCardServer, mockTransactionProcessor);
  when(mockCreditCardServer.isServerAvailable()).thenReturn(true);
  when(mockTransactionProcessor.beginTransaction()).thenReturn(transaction);
  when(mockCreditCardServer.initTransaction(transaction)).thenReturn(true);
  when(mockCreditCardServer.pay(transaction, creditCard, 500))
      .thenReturn(false);
  when(mockTransactionProcessor.endTransaction()).thenReturn(true);
  paymentProcessor.processPayment(creditCard, Money.dollars(500));
  verify(mockCreditCardServer).pay(transaction, creditCard, 500);
}

使用 fake 或真實實作重寫後,測試更簡潔且不暴露實作細節:

@Test public void creditCardIsCharged() {
  paymentProcessor =
      new PaymentProcessor(creditCardServer, transactionProcessor);
  paymentProcessor.processPayment(creditCard, Money.dollars(500));
  assertThat(creditCardServer.getMostRecentCharge(creditCard))
      .isEqualTo(500);
}

何時適合使用 Stubbing#

  • 當你需要函式回傳特定值來讓受測系統進入某種狀態時(例如回傳非空的交易列表)
  • 每個被 stub 的函式應與測試的斷言有直接關係
  • 測試通常只應 stub 少量函式——需要 stub 很多函式可能表示 stubbing 被過度使用,或受測系統過於複雜
  • 即使 stubbing 適合使用,真實實作或 fake 仍然是更好的選擇,因為它們不暴露實作細節且提供更多正確性保證

Interaction Testing 深入探討#

偏好狀態測試而非互動測試#

State testing(狀態測試)是呼叫受測系統後,驗證回傳的值是否正確或系統狀態是否正確改變:

@Test public void sortNumbers() {
  NumberSorter numberSorter = new NumberSorter(quicksort, bubbleSort);
  List sortedList = numberSorter.sortNumbers(newList(3, 1, 2));
  assertThat(sortedList).isEqualTo(newList(1, 2, 3));
}

相比之下,interaction testing 只能驗證受測系統「嘗試」做某事,但無法驗證結果是否正確:

@Test public void sortNumbers_quicksortIsUsed() {
  NumberSorter numberSorter =
      new NumberSorter(mockQuicksort, mockBubbleSort);
  numberSorter.sortNumbers(newList(3, 1, 2));
  verify(mockQuicksort).sort(newList(3, 1, 2));
}

Google 發現強調 state testing 更具擴展性——它降低測試脆弱性,使程式碼更容易變更和維護。Interaction testing 的主要問題是它無法告訴你受測系統是否真的正確運作,只能驗證某些函式是否被如預期般呼叫。Google 有人戲稱過度使用 interaction testing 的測試為 change-detector tests(變更偵測器測試),因為它們對正式程式碼的任何變更都會失敗,即使行為沒有改變。

何時適合使用 Interaction Testing#

  • 無法使用真實實作或 fake 進行 state testing 時(作為退路,提供基本的信心)
  • 函式呼叫次數或順序的差異會造成不良行為時(例如驗證快取功能是否減少了資料庫呼叫次數)

Interaction testing 不能完全取代 state testing。如果無法在單元測試中進行 state testing,強烈建議用更大範圍的測試(如整合測試)來補充,對真實實作執行 state testing。

Interaction Testing 最佳實踐#

只對狀態改變函式進行 interaction testing

函式呼叫分為兩類:

  • State-changing(改變狀態):對受測系統外部世界有副作用的函式,例如 sendEmail()saveRecord()logAccess()
  • Non-state-changing(不改變狀態):不修改任何東西、只回傳資訊的函式,例如 getUser()findResults()readFile()

一般只應對 state-changing 函式進行 interaction testing。對 non-state-changing 函式進行 interaction testing 通常是多餘的,因為受測系統會使用回傳值進行其他可斷言的工作。

避免過度規範(Avoid Overspecification)

應避免過度規範要驗證哪些函式和參數,這樣能讓測試更清晰、簡潔,且對範圍外的行為變更有韌性。

過度規範的反例:

@Test public void displayGreeting_renderUserName() {
  when(mockUserService.getUserName()).thenReturn("Fake User");
  userGreeter.displayGreeting();
  // 如果 setText() 的任何參數改變,測試就會失敗
  verify(userPrompt).setText("Fake User", "Good morning!", "Version 2.1");
  // 如果 setIcon() 沒被呼叫,測試也會失敗
  verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}

良好規範的正例——將行為拆分到獨立的測試,每個測試只驗證最少量的必要內容:

@Test public void displayGreeting_renderUserName() {
  when(mockUserService.getUserName()).thenReturn("Fake User");
  userGreeter.displayGreeting();
  verify(userPrompter).setText(eq("Fake User"), any(), any());
}

@Test public void displayGreeting_timeIsMorning_useMorningSettings() {
  setTimeOfDay(TIME_MORNING);
  userGreeter.displayGreeting();
  verify(userPrompt).setText(any(), eq("Good morning!"), any());
  verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}

結論#

Test double 是工程速度的關鍵工具,能幫助全面測試程式碼並確保測試快速執行。但不當使用會嚴重拖累生產力,導致不清晰、脆弱且低效的測試。工程師理解 test double 的最佳實踐至關重要。

真實實作與 test double 之間、以及不同 test double 技術之間,往往沒有絕對的答案,工程師需要根據具體情況做出權衡。雖然 test double 善於處理測試中難以使用的依賴,但要最大化對程式碼的信心,最終仍需在測試中執行這些依賴——這就是下一章更大範圍測試的主題。

TL;DRs#

  • 真實實作應優先於 test double
  • 如果無法在測試中使用真實實作,fake 通常是最理想的方案
  • 過度使用 stubbing 會導致不清晰且脆弱的測試
  • 應盡量避免 interaction testing:它會導致脆弱的測試,因為暴露了受測系統的實作細節