本章涵蓋互動測試(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 是否被正確呼叫?
解法是同時使用 stub 和 mock:
- 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 針對 EmailServicepublic 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 對於大型介面或複雜的互動測試場景而言不夠方便,下一章將介紹隔離框架來解決這些問題