Test Doubles(測試替身)

是什麼?

Test Double 是 Gerard Meszaros 在《xUnit Test Patterns》中定義的統稱,指所有在測試中替代真實依賴的物件。常見的四種 Test Double:Stub、Mock、Fake、Spy。

ℹ️術語澄清

很多人把所有測試替身都叫「Mock」,但嚴格來說 Mock 只是 Test Double 的一種。理解它們的差異能幫你選擇最適合的工具。

核心觀念

常見誤區

⚠️Test Double 的陷阱

  • 過度 Mock:每個依賴都 Mock 會讓測試與實作高度耦合,重構就全部爆掉
  • Mock 了不該 Mock 的東西:不要 Mock 值物件(Value Object)或純函式
  • Stub 和 Mock 混淆:Stub 用來控制輸入,Mock 用來驗證輸出和互動,目的不同
  • Fake 太複雜:如果 Fake 需要自己的測試,代表它太複雜了

選擇流程

1

確認依賴類型

這個依賴是查詢(回傳資料)還是命令(產生副作用)?

2

查詢用 Stub

只需要控制回傳值,讓被測物件走到特定路徑

3

命令用 Mock/Spy

需要驗證是否正確呼叫了外部服務(如寄信、寫入資料庫)

4

複雜依賴用 Fake

需要更接近真實行為時(如 In-Memory DB),使用 Fake

5

填參數用 Dummy

只為了滿足方法簽名,不會被實際呼叫

程式碼範例

C#(xUnit + Moq)

csharp
// 被測試的服務
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)

typescript
// 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)

python
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 (驗證互動)
Fake (簡化實作)
Spy (記錄呼叫)
Dummy (填充參數)

此圖展示 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 | 僅填充參數位置 | 不會被使用的建構參數 |

你可能也想看

Red-Green-Refactor單元測試最佳實踐

按 ← → 鍵切換課程