本章涵蓋互動測試(interaction testing)的核心概念,說明如何使用 mock 物件來驗證被測試程式碼與外部相依物件之間的互動是否正確。前幾章處理的是「回傳值」與「狀態變化」兩種結果類型,本章則聚焦於第三種:呼叫第三方物件

值測試 vs. 狀態測試 vs. 互動測試#

一個工作單元(unit of work)可以產生三種最終結果:

  • 值測試(Value-based testing):檢查函式回傳的值是否正確
  • 狀態測試(State-based testing):檢查系統在操作之後的狀態變化
  • 互動測試(Interaction testing):檢查物件是否正確地對其他物件發送訊息(呼叫方法)

互動測試是指測試一個物件如何向其他物件發送訊息(呼叫方法)。當呼叫另一個物件本身就是工作單元的最終結果時,才使用互動測試。

作者以灌溉系統為例來說明兩者的差異:

  • 狀態測試(整合測試):讓系統運行 12 小時後,檢查土壤是否夠濕、樹葉是否翠綠——這需要整個環境配合,耗時且複雜
  • 互動測試:在灌溉水管末端裝一個記錄裝置,記錄水流量和時間,只需驗證裝置是否在正確的時間被呼叫了正確的次數——不需要真的種一棵樹

應該始終優先選擇值測試或狀態測試,只在不得已時才使用互動測試。互動測試會讓測試變得更複雜、更脆弱,維護成本也更高。只有當「呼叫另一個物件」本身就是最終結果(例如呼叫第三方 logger)時,才適合使用互動測試。

Mock 與 Stub 的差異#

理解 mock 和 stub 的區別非常重要,因為許多工具和框架使用這兩個術語來描述不同的行為。核心差異在於:

  • Stub 永遠不會讓測試失敗——assert 是針對被測試的類別執行的
  • Mock 可以讓測試失敗——assert 是針對 mock 物件執行的

Figure 4.1: When using a stub, the assert is performed on the class under test

使用 stub 時,測試對被測試類別進行斷言。Stub 只是輔助測試順利運行,本身不決定測試的成敗。

Figure 4.2: The class under test communicates with the mock object, and all communication is recorded

使用 mock 時,被測試類別與 mock 物件溝通,所有溝通都會被記錄下來。測試透過 mock 物件來驗證測試是否通過。

使用 Stub 時的測試流程:

sequenceDiagram
    participant Test as 測試
    participant SUT as 被測試類別
    participant Stub
    Test->>SUT: 呼叫方法
    SUT->>Stub: 取得假資料
    Stub-->>SUT: 回傳預設值
    SUT-->>Test: 回傳結果
    Note over Test,SUT: Assert 針對被測試類別

使用 Mock 時的測試流程:

sequenceDiagram
    participant Test as 測試
    participant SUT as 被測試類別
    participant Mock
    Test->>SUT: 呼叫方法
    SUT->>Mock: 呼叫方法
    Note over Mock: 記錄所有呼叫
    Note over Test,Mock: Assert 針對 Mock 物件

關鍵定義#

  • Fake:泛用術語,可以指 stub 或 mock。一個 fake 到底是 stub 還是 mock,取決於它在當前測試中的使用方式。如果用來做斷言驗證(assert against),就是 mock;否則就是 stub
  • Mock 物件:系統中的一個假物件,用來決定單元測試是否通過或失敗。它透過驗證被測試物件是否如預期般呼叫了該假物件來做判斷。通常每個測試不超過一個 mock

手動撰寫 Mock 的範例#

建立和使用 mock 物件與使用 stub 非常類似,差別在於 mock 會保存溝通歷史,之後以**期望(expectations)**的形式進行驗證。

情境設定#

LogAnalyzer 需要與外部 web service 互動:當遇到檔名太短時,要發送錯誤訊息到 web service。由於 web service 可能尚未就緒或執行太慢,需要建立一個可替代的介面。

首先抽取介面:

public interface IWebService
{
    void LogError(string message);
}

接著建立手寫的 mock 物件:

public class FakeWebService : IWebService
{
    public string LastError;

    public void LogError(string message)
    {
        LastError = message;
    }
}

這個類別實作了介面,並將收到的訊息儲存下來,讓測試可以在事後進行驗證。

Figure 4.3: The test creates a mock object to record messages that LogAnalyzer will send

根據 Gerard Meszaros 在 xUnit Test Patterns: Refactoring Test Code 一書中的分類,這種手寫的 fake 物件在用來記錄呼叫歷史時,稱為 Test Spy

測試程式碼#

[Test]
public void Analyze_TooShortFileName_CallsWebService()
{
    FakeWebService mockService = new FakeWebService();
    LogAnalyzer log = new LogAnalyzer(mockService);
    string tooShortFileName = "abc.ext";

    log.Analyze(tooShortFileName);

    // 斷言是針對 mock 物件,而非被測試類別
    StringAssert.Contains("Filename too short:abc.ext",
                          mockService.LastError);
}
public class LogAnalyzer
{
    private IWebService service;

    public LogAnalyzer(IWebService service)
    {
        this.service = service;
    }

    public void Analyze(string fileName)
    {
        if (fileName.Length < 8)
        {
            service.LogError("Filename too short:" + fileName);
        }
    }
}

注意 assert 是針對 mock 物件而非 LogAnalyzer 類別。這是因為我們測試的是 LogAnalyzer 與 web service 之間的互動。使用的 DI 技術與第三章相同,但此處 mock 物件決定了測試的成敗。另外,assert 不要寫在 mock 物件內部,而是寫在測試方法中,這樣可以提高可讀性和可重用性。

同時使用 Mock 和 Stub#

考慮一個更複雜的場景:LogAnalyzer 不僅需要呼叫 web service,當 web service 拋出例外時,還需要透過 email 通知系統管理員。

Figure 4.4: LogAnalyzer has two external dependencies: web service and email service

程式邏輯如下:

if (fileName.Length < 8)
{
    try
    {
        service.LogError("Filename too short:" + fileName);
    }
    catch (Exception e)
    {
        email.SendEmail("a", "subject", e.Message);
    }
}

這段邏輯只涉及與外部物件的互動,沒有回傳值也沒有狀態變化。測試時面臨三個問題:

  • 如何替換 web service?
  • 如何模擬 web service 拋出例外,以便測試 email 呼叫?
  • 如何知道 email service 是否被正確呼叫?

解法是同時使用 stubmock

  • Web service 是 stub:模擬拋出例外,不對其做斷言
  • Email service 是 mock:驗證它是否被正確呼叫

Figure 4.5: The web service is a mock and the email service is a stub

sequenceDiagram
    participant Test as 測試
    participant LA as LogAnalyzer
    participant WS as WebService
    participant Email as EmailService
    Note over WS: Stub
    Note over Email: Mock
    Test->>LA: Analyze(tooShortFileName)
    LA->>WS: LogError()
    WS--xLA: 拋出例外
    LA->>Email: SendEmail()
    Note over Test,Email: Assert 針對 EmailService
public interface IEmailService
{
    void SendEmail(string to, string subject, string body);
}

public class FakeWebService : IWebService
{
    public Exception ToThrow;
    public void LogError(string message)
    {
        if (ToThrow != null)
        {
            throw ToThrow;
        }
    }
}

public class FakeEmailService : IEmailService
{
    public string To;
    public string Subject;
    public string Body;

    public void SendEmail(string to, string subject, string body)
    {
        To = to;
        Subject = subject;
        Body = body;
    }
}

測試程式碼:

[Test]
public void Analyze_WebServiceThrows_SendsEmail()
{
    FakeWebService stubService = new FakeWebService();
    stubService.ToThrow = new Exception("fake exception");

    FakeEmailService mockEmail = new FakeEmailService();

    LogAnalyzer2 log = new LogAnalyzer2(stubService, mockEmail);

    string tooShortFileName = "abc.ext";
    log.Analyze(tooShortFileName);

    StringAssert.Contains("someone@somewhere.com", mockEmail.To);
    StringAssert.Contains("fake exception", mockEmail.Body);
    StringAssert.Contains("can't log", mockEmail.Subject);
}

如果一個測試中有多個 assert 且擔心前面的失敗會跳過後面的驗證,可以考慮建立一個 EmailInfo 預期物件,將三個屬性包成一個物件做比對,只需一次 Assert.AreEqual 即可。

每個測試一個 Mock#

在一個測試中(建議只測試一件事),不應有超過一個 mock 物件。所有其他的 fake 物件都應該作為 stub 使用。

如果一個測試中有多個 mock,通常意味著你正在同時測試多件事,這會導致測試變得複雜且脆弱。

遵循此原則,面對複雜測試時可以問自己:「哪個是我的 mock?」找到之後,其他的 fake 就是 stub,不需要對它們做斷言。

過度規範(Overspecification) 是指在測試中指定了太多不該關心的事情,例如驗證 stub 是否被呼叫。這些額外的規範會導致測試因為錯誤的原因而失敗——你修改了正式程式碼的內部實作,但最終結果仍然正確,測試卻開始哀嚎。長此以往,你會不斷修改測試來配合實作細節,最終厭煩而開始刪除測試。應該只驗證工作單元的三種最終結果之一。

Fake 鏈:Stub 產生 Mock 或其他 Stub#

有時你需要一個 fake 物件的方法或屬性回傳另一個 fake 物件,形成一條 fake 鏈。這在系統設計中有複雜物件鏈時很常見,例如:

IServiceFactory factory = GetServiceFactory();
IService service = factory.GetService();

或是:

String connstring =
    GlobalUtil.Configuration.DBConfiguration.ConnectionString;

在測試中,你可以把 Configuration 屬性設為 stub,再把其上的 DBConfiguration 設為另一個 stub,層層替換直到最終回傳你需要的 mock 或 stub 值。

考慮是否能重構程式碼來避免呼叫鏈。例如可以用虛擬方法封裝:

String connstring = GetConnectionString();
protected virtual string GetConnectionString()
{
    return GlobalUtil.Configuration.DBConfiguration.ConnectionString;
}

這樣更容易閱讀和維護,也不需要插入多層 stub。另一種方式是建立特殊的 wrapper 類別來簡化 API,Michael Feathers 在 Working Effectively with Legacy Code 中稱之為 Adapt Parameter 模式。

手動撰寫 Mock 和 Stub 的問題#

手動撰寫 mock 和 stub 存在以下問題:

問題說明
耗時每個介面都需要手工建立對應的 fake 類別
難以處理複雜介面當介面有許多方法、屬性和事件時,手寫變得非常困難
樣板程式碼過多要記錄 mock 方法的多次呼叫狀態,需要大量重複程式碼
參數驗證困難要驗證方法呼叫的所有參數是否正確,需要撰寫多個 assert
難以重用基本的 fake 可以重用,但當介面方法增多,維護成本快速上升
Mock 兼任 Stub 的尷尬極少數情況下,一個 fake 需要同時扮演 mock 和 stub 的角色(例如要驗證的方法同時需要回傳值給呼叫方),這時手寫程式碼會變得更加混亂

這些問題將在下一章介紹的隔離(mocking)框架中得到解決。框架可以在執行時期自動建立 stub 和 mock 物件,更簡潔且更不容易出錯。

總結#

本章的核心要點:

  • Mock 物件類似 stub,但它也能幫助你驗證測試結果。Stub 永遠不會讓測試失敗,它只負責模擬各種情境;mock 物件則可以透過驗證互動來決定測試成敗
  • 在同一個測試中結合 stub 和 mock 是強大的技巧,但要注意每個測試最多一個 mock,其餘的 fake 物件應該是 stub
  • 產生其他 stub 或 mock 的 stub(fake 鏈)是將假相依物件注入使用工廠類別和方法的程式碼的好方式,但要考慮是否能透過重構來簡化
  • **過度規範(overspecification)**是撰寫互動測試時最常見的問題。應該極少驗證同時作為 mock 和 stub 的 fake 物件的呼叫。一個測試中可以有多個 stub,但要確保測試保持可讀性
  • 手動撰寫 mock 和 stub 對於大型介面或複雜的互動測試場景而言不夠方便,下一章將介紹隔離框架來解決這些問題