Test Doubles(測試替身)
是什麼?
Test Double 是 Gerard Meszaros 在《xUnit Test Patterns》中定義的統稱,指所有在測試中替代真實依賴的物件。常見的四種 Test Double:Stub、Mock、Fake、Spy。
ℹ️術語澄清
很多人把所有測試替身都叫「Mock」,但嚴格來說 Mock 只是 Test Double 的一種。理解它們的差異能幫你選擇最適合的工具。
核心觀念
- Stub:回傳預設值的替身,控制被測物件的輸入。「你問我什麼,我照劇本回答。」例如:Repository 的
GetById永遠回傳一個固定的 User 物件,讓你測試「找到使用者後的處理邏輯」 - Mock:帶有驗證期望的替身,檢查互動是否正確發生。「我會記錄你怎麼叫我,最後驗證。」例如:驗證
EmailSender.Send()是否被呼叫了一次,且參數正確 - Fake:有簡化實作的替身,如 In-Memory Database。「我是簡化版的真東西。」例如:用
Dictionary實作的 InMemoryRepository,有真實的 CRUD 行為但不碰資料庫 - Spy:記錄呼叫紀錄的替身,事後查詢互動細節。「我默默記下一切,你事後再問我。」和 Mock 的差異在於:Mock 在測試結束時主動驗證(
Verify),Spy 只記錄、由測試程式碼事後查詢(HaveBeenCalled) - Dummy:僅用於填充參數,不會被實際使用。例如:建構子需要
ILogger但這次測試不測 log,傳一個空實作即可
常見誤區
⚠️Test Double 的陷阱
- 過度 Mock:每個依賴都 Mock 會讓測試與實作高度耦合,重構就全部爆掉
- Mock 了不該 Mock 的東西:不要 Mock 值物件(Value Object)或純函式
- Stub 和 Mock 混淆:Stub 用來控制輸入,Mock 用來驗證輸出和互動,目的不同
- Fake 太複雜:如果 Fake 需要自己的測試,代表它太複雜了
選擇流程
確認依賴類型
這個依賴是查詢(回傳資料)還是命令(產生副作用)?
查詢用 Stub
只需要控制回傳值,讓被測物件走到特定路徑
命令用 Mock/Spy
需要驗證是否正確呼叫了外部服務(如寄信、寫入資料庫)
複雜依賴用 Fake
需要更接近真實行為時(如 In-Memory DB),使用 Fake
填參數用 Dummy
只為了滿足方法簽名,不會被實際呼叫
程式碼範例
C#(xUnit + Moq)
// 被測試的服務
public interface IEmailSender
{
void Send(string to, string body);
}
public interface IUserRepository
{
User? GetById(int id);
}
public class NotificationService
{
private readonly IEmailSender _sender;
private readonly IUserRepository _repo;
public NotificationService(IEmailSender sender, IUserRepository repo)
{
_sender = sender;
_repo = repo;
}
public bool NotifyUser(int userId, string message)
{
var user = _repo.GetById(userId);
if (user == null) return false;
_sender.Send(user.Email, message);
return true;
}
}
// Stub: 控制 Repository 回傳值
// Mock: 驗證 Email 是否被呼叫
[Fact]
public void NotifyUser_UserExists_SendsEmail()
{
var stubRepo = new Mock<IUserRepository>();
stubRepo.Setup(r => r.GetById(1))
.Returns(new User { Email = "test@example.com" });
var mockSender = new Mock<IEmailSender>();
var service = new NotificationService(mockSender.Object, stubRepo.Object);
var result = service.NotifyUser(1, "Hello");
Assert.True(result);
mockSender.Verify(s => s.Send("test@example.com", "Hello"), Times.Once);
}TypeScript(Jest)
// Stub: 控制回傳值
const stubRepo = {
getById: jest.fn().mockReturnValue({ email: "test@example.com" }),
};
// Spy: 記錄呼叫,事後驗證
const spySender = {
send: jest.fn(),
};
describe("NotificationService", () => {
it("sends email when user exists", () => {
const service = new NotificationService(spySender, stubRepo);
const result = service.notifyUser(1, "Hello");
expect(result).toBe(true);
expect(spySender.send).toHaveBeenCalledWith("test@example.com", "Hello");
expect(spySender.send).toHaveBeenCalledTimes(1);
});
it("returns false when user not found", () => {
stubRepo.getById.mockReturnValue(null);
const service = new NotificationService(spySender, stubRepo);
const result = service.notifyUser(999, "Hello");
expect(result).toBe(false);
expect(spySender.send).not.toHaveBeenCalled();
});
});Python(pytest + unittest.mock)
from unittest.mock import Mock, patch
# Stub: 控制回傳值
def test_notify_user_sends_email():
stub_repo = Mock()
stub_repo.get_by_id.return_value = User(email="test@example.com")
mock_sender = Mock()
service = NotificationService(mock_sender, stub_repo)
result = service.notify_user(1, "Hello")
assert result is True
mock_sender.send.assert_called_once_with("test@example.com", "Hello")
# Spy 風格:記錄呼叫並驗證
def test_notify_user_not_found():
stub_repo = Mock()
stub_repo.get_by_id.return_value = None
spy_sender = Mock()
service = NotificationService(spy_sender, stub_repo)
result = service.notify_user(999, "Hello")
assert result is False
spy_sender.send.assert_not_called()關係圖
此圖展示 Test Double 是五種替身的統稱。選擇依據:依賴是查詢型的用 Stub,依賴有副作用需要驗證的用 Mock/Spy,需要接近真實行為的用 Fake,純佔位的用 Dummy。
測試框架中的 Mock 工具比較
| 語言 | 工具 | 特色 | 選擇時機 |
|------|------|------|----------|
| C# | Moq | 流暢 API、Lambda Setup,社群最大 | 大多數 C# 專案的預設選擇 |
| C# | NSubstitute | 語法更簡潔(sub.GetById(1).Returns(user)),無需 .Object | 偏好簡潔語法的團隊 |
| C# | FakeItEasy | 命名直觀(A.CallTo(...).Returns(...)) | 可讀性優先的團隊 |
| TS/JS | Jest 內建 | jest.fn() / jest.spyOn(),無需額外安裝 | 用 Jest 的專案 |
| TS/JS | Vitest 內建 | API 與 Jest 相容,搭配 Vite 零配置 | 用 Vitest 的專案 |
| Python | unittest.mock | 標準庫內建,Mock() / patch() | Python 專案的預設選擇 |
| Python | pytest-mock | 提供 mocker fixture,語法更 pytest 風格 | 用 pytest 的專案 |
實戰補充
💡資深開發者的經驗
- 優先用 Stub,慎用 Mock:過度使用 Mock 會讓測試變成「重新實作一次被測程式碼」。只在驗證重要副作用(寄信、付款)時才用 Mock。
- 常用框架:C# 用 Moq 或 NSubstitute,TypeScript 用 Jest 內建 mock,Python 用 unittest.mock。
- Fake 的投資報酬率高:例如用 SQLite In-Memory 替代 SQL Server,既快又接近真實行為。
- London School vs. Chicago School:London School(又稱 Mockist)偏好大量 Mock 來隔離每個單元,每個類別的測試只關心它直接互動的物件;Chicago School(又稱 Classicist)偏好真實物件,只在系統邊界(HTTP、DB、外部 API)Mock。選擇標準:如果你的程式碼有大量副作用和外部依賴,London School 更實用;如果主要是純邏輯運算,Chicago School 更穩固
面試常見問題
Q:什麼時候該用 Stub,什麼時候該用 Mock?
A:遵循 Command-Query Separation(CQS)原則:如果依賴的方法是 Query(回傳資料、無副作用),用 Stub 控制回傳值;如果依賴的方法是 Command(有副作用、如寄信、扣款),用 Mock 驗證呼叫。具體判斷:問自己「我關心的是回傳值還是互動行為?」關心回傳值用 Stub,關心行為用 Mock。
Q:過度 Mock 會造成什麼問題?
A:(1) 測試與實作高度耦合——重構內部結構時測試全部壞掉,即使行為沒變;(2) 虛假的安全感——測試只驗證了 Mock 框架能正確運作,沒碰到真正的業務邏輯;(3) 維護成本暴增——每改一行程式碼就要改十行測試的 Setup。解法:只 Mock 你無法控制的外部邊界,領域邏輯用真實物件。
理解測驗
🤔 Stub 和 Mock 的主要差異是什麼?
🤔 以下哪個是 Fake 的典型例子?
🤔 什麼時候不該使用 Test Double?
重點整理
💡一句話記住
口訣:「Stub 餵料、Mock 盯場、Fake 演替身、Spy 偷拍、Dummy 站位」—— 五種替身各司其職。
| 類型 | 用途 | 典型場景 | |------|------|----------| | Stub | 回傳預設值,控制測試路徑 | Repository 回傳假資料 | | Mock | 驗證互動是否正確發生 | 確認 Email 被寄出 | | Fake | 簡化但可運作的實作 | In-Memory Database | | Spy | 記錄呼叫,事後查詢 | 檢查 log 被寫入幾次 | | Dummy | 僅填充參數位置 | 不會被使用的建構參數 |