- Published on
Test Double 測試替身入門:五種類型一次搞懂

為什麼我們需要了解 Test Double
在寫單元測試時,你一定聽過「mock」這個詞。但如果仔細觀察,你會發現幾乎所有人都在「非正式地」使用這個詞。有人說「我 mock 了一個資料庫」,有人說「這個 mock 會回傳 true」,還有人說「記得 mock 那個 API」
其實,mock 只是 Test Double(測試替身)的其中一種。Gerard Meszaros 在他的著作《xUnit Test Patterns》中定義了五種 Test Double:Dummy、Stub、Spy、Mock、Fake。這個「Test Double」的名稱來自電影產業中的「替身演員」(Stunt Double)概念。就像電影中替身演員代替真正的演員完成危險動作一樣,Test Double 在測試中「頂替」真正的依賴
Robert C. Martin(Uncle Bob)在《Clean Craftsmanship》書中提到一個有趣的觀點
“幾乎所有人都在非正式地使用 ‘mock’ 這個詞。現代框架叫做 ‘mocking framework’,但它們產生的物件通常不是嚴格定義的 Mock”
理解這五種類型的差異,在團隊溝通和選擇工具時會更有幫助
被測試的系統:LoginDialog
在介紹五種 Test Double 之前,先看看本文要測試的系統
// Authenticator 介面
// 定義認證服務
export class Authenticator {
authenticate(username, password) {
throw new Error('Not implemented - this is an interface')
}
}
// 被測試的主角:LoginDialog
// 這個類別依賴 Authenticator 來處理認證,是我們要測試的目標
export class LoginDialog {
constructor(authenticator) {
// 依賴注入:接收 Authenticator 實例,方便在測試時替換為 Test Double
this.authenticator = authenticator
this.isShown = false
this.loginAttempts = 0
this.maxAttempts = 3
}
show() {
this.isShown = true
}
close() {
this.isShown = false
}
submit(username, password) {
// 前置條件檢查:Dialog 必須先顯示
if (!this.isShown) {
throw new Error('Dialog must be shown before submit')
}
// 追蹤登入嘗試次數
this.loginAttempts++
// 超過最大嘗試次數,直接拒絕(不呼叫 authenticator)
if (this.loginAttempts > this.maxAttempts) {
return { success: false, message: 'Too many attempts' }
}
// 委派給 authenticator 處理實際的認證邏輯
// 這裡就是 Test Double 介入的關鍵點
const result = this.authenticator.authenticate(username, password)
// 根據認證結果回傳對應訊息
if (result) {
return { success: true, message: 'Welcome!' }
} else {
return {
success: false,
message: `Login failed. ${this.maxAttempts - this.loginAttempts} attempts remaining.`,
}
}
}
getLoginAttempts() {
return this.loginAttempts
}
}
這個 LoginDialog 依賴於 Authenticator 來處理認證邏輯。在測試時,我們需要用 Test Double 來取代真正的 Authenticator
1. Dummy(啞巴)
定義
Dummy 是最簡單的 Test Double。它實作介面但什麼都不做,所有方法回傳 null/0/false 等預設值
Uncle Bob 在書中說
“A dummy is an implementation that does nothing.”
使用時機
- 測試不需要用到這個依賴
- 只是為了滿足建構子或方法簽章的需求
手工實作範例
// Dummy:最簡單的 Test Double
// 特徵:實作介面但什麼都不做,只回傳預設值
class AuthenticatorDummy extends Authenticator {
authenticate(username, password) {
// 回傳 false 只是為了滿足介面要求
// 在使用 Dummy 的測試中,這個回傳值不會被用到
return false
}
}
describe('1. DUMMY - 什麼都不做', () => {
it('測試 Dialog 可以正常顯示(不關心認證功能)', () => {
// 建立 Dummy:我們不在乎認證功能,只是需要傳入一個物件
const dummy = new AuthenticatorDummy()
const dialog = new LoginDialog(dummy)
// 這個測試只關心 show() 是否正常運作
// Dummy 的 authenticate() 不會被呼叫
dialog.show()
expect(dialog.isShown).toBe(true)
})
})
在這個測試中,我們只想驗證 dialog.show() 是否正常運作。我們根本不關心 Authenticator,但 LoginDialog 的建構子需要它,所以我們傳入一個 Dummy
2. Stub(樁)
定義
Stub 是 Dummy 加上回傳測試所需的特定值。它用來驅動受測系統走向特定的執行路徑
Uncle Bob 在書中說
“A stub is a dummy that returns test-specific values in order to drive the system under test through desired pathways.”
使用時機
- 需要控制依賴的回傳值
- 想要測試系統在不同輸入下的行為
手工實作範例
// Stub:Dummy + 可控制的回傳值
// 特徵:透過建構子或設定方法指定回傳值,用來驅動測試走向特定路徑
class AuthenticatorStub extends Authenticator {
constructor(resultToReturn) {
super()
// 在建立時就決定要回傳什麼值
this.resultToReturn = resultToReturn
}
authenticate(username, password) {
// 忽略輸入參數,直接回傳預設好的值
// 這讓測試可以精確控制認證的結果
return this.resultToReturn
}
}
describe('2. STUB - 控制回傳值', () => {
it('當認證成功時,登入應該成功', () => {
// 建立一個「永遠回傳 true」的 Stub
const stub = new AuthenticatorStub(true)
const dialog = new LoginDialog(stub)
dialog.show()
// 不管帳密是什麼,Stub 都會回傳 true
const result = dialog.submit('any', 'any')
// 驗證 LoginDialog 在認證成功時的行為
expect(result.success).toBe(true)
expect(result.message).toBe('Welcome!')
})
it('當認證失敗時,登入應該失敗', () => {
// 建立一個「永遠回傳 false」的 Stub
const stub = new AuthenticatorStub(false)
const dialog = new LoginDialog(stub)
dialog.show()
const result = dialog.submit('any', 'any')
// 驗證 LoginDialog 在認證失敗時的行為
expect(result.success).toBe(false)
})
})
透過 Stub,我們可以精確控制 authenticate() 的回傳值,測試 LoginDialog 在認證成功和失敗時的不同行為
3. Spy(間諜)
定義
Spy 是 Stub 加上記錄功能。它會「記住」發生了什麼事,包括方法被呼叫了幾次、傳入了什麼參數等
Uncle Bob 在書中說
“A spy remembers what was done to it.”
使用時機
- 需要驗證受測系統「怎麼」呼叫依賴
- 確認參數是否正確傳遞
- 確認方法是否被呼叫(以及呼叫幾次)
手工實作範例
// Spy:Stub + 記錄功能
// 特徵:除了回傳值,還會記錄所有互動細節供測試查詢
class AuthenticatorSpy extends Authenticator {
constructor() {
super()
// Stub 功能:可控制的回傳值
this.resultToReturn = false
// Spy 功能:記錄互動的狀態
this.callCount = 0
this.lastUsername = null
this.lastPassword = null
this.callHistory = [] // 完整的呼叫歷史
}
// Stub 功能:設定回傳值
setResult(result) {
this.resultToReturn = result
}
authenticate(username, password) {
// Spy 功能:記錄這次呼叫的所有細節
this.callCount++
this.lastUsername = username
this.lastPassword = password
this.callHistory.push({ username, password, timestamp: Date.now() })
// Stub 功能:回傳預設值
return this.resultToReturn
}
// Spy 特有:查詢方法,讓測試可以檢查互動細節
getCallCount() {
return this.callCount
}
getLastUsername() {
return this.lastUsername
}
getLastPassword() {
return this.lastPassword
}
// 檢查是否曾經用特定參數呼叫過
wasCalledWith(username, password) {
return this.callHistory.some((call) => call.username === username && call.password === password)
}
}
describe('3. SPY - 記錄互動', () => {
it('應該正確傳遞帳號密碼給 Authenticator', () => {
const spy = new AuthenticatorSpy()
spy.setResult(true)
const dialog = new LoginDialog(spy)
dialog.show()
dialog.submit('bob', 'secret123')
// Spy 讓我們可以驗證「怎麼呼叫」而不只是「結果是什麼」
expect(spy.getCallCount()).toBe(1)
expect(spy.getLastUsername()).toBe('bob')
expect(spy.getLastPassword()).toBe('secret123')
})
it('多次登入失敗時,應該記錄每次呼叫', () => {
const spy = new AuthenticatorSpy()
spy.setResult(false)
const dialog = new LoginDialog(spy)
dialog.show()
dialog.submit('user1', 'pass1')
dialog.submit('user2', 'pass2')
// 透過 Spy 驗證完整的呼叫歷史
expect(spy.getCallCount()).toBe(2)
expect(spy.wasCalledWith('user1', 'pass1')).toBe(true)
expect(spy.wasCalledWith('user2', 'pass2')).toBe(true)
})
})
Spy 讓我們能夠驗證「互動」而不只是「結果」。這在測試事件發送、日誌記錄等場景特別有用
4. Mock(模擬物件)
定義
Mock 是 Spy 加上內建的預期與驗證邏輯。斷言被寫進 Mock 物件內部,而不是測試程式碼中
Uncle Bob 在書中說
“A mock also knows what to expect and will pass or fail the test on the basis of those expectations. In other words, the test assertions are written into the mock.”
Uncle Bob 也提到他的個人看法
“I don’t much care for mocks. They couple the spy behavior to the test assertions. That bothers me.”
使用時機
- 需要在執行前就定義好「應該怎麼被呼叫」(這是與 Spy 的關鍵差異:Spy 是事後驗證,Mock 是事前預期)
- 需要在替身內部設定預期行為
- 預期複雜的互動序列
手工實作範例
// Mock:Spy + 內建的預期驗證邏輯
// 特徵:斷言被寫進 Mock 內部,測試只需呼叫 verify()
// 與 Spy 的關鍵差異:Spy 的斷言寫在測試裡,Mock 的斷言寫在 Mock 裡
class AuthenticatorMock extends Authenticator {
constructor() {
super()
// Stub 功能
this.resultToReturn = false
// Spy 功能:記錄呼叫
this.callCount = 0
this.lastUsername = null
this.lastPassword = null
// Mock 特有:預期設定(這是與 Spy 的關鍵差異)
this.expectedUsername = null
this.expectedPassword = null
this.expectedCallCount = null
}
setResult(result) {
this.resultToReturn = result
}
// Mock 特有:在執行前設定「預期」
// 這些預期會在 verify() 時被檢查
expectToBeCalledWith(username, password) {
this.expectedUsername = username
this.expectedPassword = password
}
expectCallCount(count) {
this.expectedCallCount = count
}
authenticate(username, password) {
// Spy 功能:記錄呼叫
this.callCount++
this.lastUsername = username
this.lastPassword = password
return this.resultToReturn
}
// Mock 特有:驗證預期是否被滿足
// 斷言邏輯被封裝在這裡,測試只需呼叫 verify()
verify() {
const errors = []
// 檢查呼叫次數是否符合預期
if (this.expectedCallCount !== null && this.callCount !== this.expectedCallCount) {
errors.push(`Expected ${this.expectedCallCount} calls, but got ${this.callCount}`)
}
// 檢查 username 是否符合預期
if (this.expectedUsername !== null && this.lastUsername !== this.expectedUsername) {
errors.push(`Expected username "${this.expectedUsername}", but got "${this.lastUsername}"`)
}
// 檢查 password 是否符合預期
if (this.expectedPassword !== null && this.lastPassword !== this.expectedPassword) {
errors.push(`Expected password "${this.expectedPassword}", but got "${this.lastPassword}"`)
}
return { success: errors.length === 0, errors }
}
}
Spy vs Mock:關鍵差異
這是最容易混淆的地方,讓我們用程式碼來對比
// SPY 寫法:斷言寫在測試裡面
it('【SPY 寫法】斷言寫在測試裡面', () => {
const spy = new AuthenticatorSpy()
spy.setResult(true)
const dialog = new LoginDialog(spy)
dialog.show()
dialog.submit('bob', 'secret')
// ✅ 斷言「明確寫在測試裡」
// 優點:一眼就能看出在驗證什麼
expect(spy.getCallCount()).toBe(1)
expect(spy.getLastUsername()).toBe('bob')
expect(spy.getLastPassword()).toBe('secret')
})
// MOCK 寫法:斷言寫在 Mock 裡面
it('【MOCK 寫法】斷言寫在 Mock 裡面', () => {
const mock = new AuthenticatorMock()
mock.setResult(true)
// ⚠️ 預期設定:在執行前就定義好「應該怎麼被呼叫」
mock.expectToBeCalledWith('bob', 'secret')
mock.expectCallCount(1)
const dialog = new LoginDialog(mock)
dialog.show()
dialog.submit('bob', 'secret')
// ⚠️ 只呼叫 verify(),斷言邏輯「藏在 Mock 裡」
// 缺點:需要看 Mock 實作才知道在驗證什麼
expect(mock.verify().success).toBe(true)
})
關鍵差異
| 比較項目 | Spy | Mock |
|---|---|---|
| 斷言位置 | 寫在測試裡面 | 寫在 Mock 裡面 |
| 測試意圖 | 一眼就看到在驗證什麼 | 需要看 Mock 實作才知道 |
| 耦合程度 | 較低 | 較高(斷言被藏起來) |
這也是為什麼 Uncle Bob 偏好 Spy 而不是 Mock 的原因
5. Fake(仿品)
定義
Fake 與前四種完全不同。它是一個獨立的類別,實作簡化版但「真正運作」的業務邏輯
Uncle Bob 在書中說
“A fake is a simulator… A fake is a different kind of test double entirely.”
他也警告
“The problem with fakes is that as the application grows… the fakes tend to grow with each new tested condition. Eventually, they can get so large and complex that they need tests of their own.”
使用時機
- 需要一個簡化但「真正運作」的替代品
- 例如:In-Memory Database、簡化的認證服務
手工實作範例
// Fake:簡化但「真正運作」的實作
// 特徵:有真實的業務邏輯,但比正式環境簡化(例如用 Map 取代資料庫)
// 與其他 Test Double 完全不同:它是一個獨立的簡化實作
class FakeAuthenticator extends Authenticator {
constructor() {
super()
// 用 Map 模擬資料庫中的使用者資料
this.users = new Map([
['admin', { password: 'admin123', role: 'admin' }],
['bob', { password: 'xyzzy', role: 'user' }],
['alice', { password: 'wonderland', role: 'user' }],
])
// 帳號鎖定機制的狀態
this.lockedAccounts = new Set()
this.failedAttempts = new Map()
}
authenticate(username, password) {
// 真實的業務邏輯:檢查帳號是否被鎖定
if (this.lockedAccounts.has(username)) {
return false
}
// 真實的業務邏輯:檢查使用者是否存在
const user = this.users.get(username)
if (!user) {
return false
}
// 真實的業務邏輯:驗證密碼
if (user.password === password) {
// 登入成功,清除失敗記錄
this.failedAttempts.delete(username)
return true
} else {
// 真實的業務邏輯:記錄失敗次數並處理帳號鎖定
const attempts = (this.failedAttempts.get(username) || 0) + 1
this.failedAttempts.set(username, attempts)
// 失敗 3 次就鎖定帳號
if (attempts >= 3) {
this.lockedAccounts.add(username)
}
return false
}
}
// 測試輔助方法:讓測試可以設定初始狀態
addUser(username, password, role = 'user') {
this.users.set(username, { password, role })
}
lockAccount(username) {
this.lockedAccounts.add(username)
}
unlockAccount(username) {
this.lockedAccounts.delete(username)
this.failedAttempts.delete(username)
}
// 測試輔助方法:讓測試可以查詢內部狀態
isLocked(username) {
return this.lockedAccounts.has(username)
}
}
describe('5. FAKE - 簡化的真實實作', () => {
it('有效的使用者可以登入', () => {
const fake = new FakeAuthenticator()
const dialog = new LoginDialog(fake)
dialog.show()
// Fake 會真正驗證帳密是否正確
const result = dialog.submit('bob', 'xyzzy')
expect(result.success).toBe(true)
})
it('密碼錯誤 3 次後帳號會被鎖定', () => {
const fake = new FakeAuthenticator()
const dialog = new LoginDialog(fake)
dialog.show()
// Fake 會真正追蹤失敗次數並執行鎖定邏輯
dialog.submit('bob', 'wrong1')
dialog.submit('bob', 'wrong2')
dialog.submit('bob', 'wrong3')
// 驗證 Fake 的內部狀態
expect(fake.isLocked('bob')).toBe(true)
// 即使密碼正確,被鎖定的帳號也無法登入
const result = dialog.submit('bob', 'xyzzy')
expect(result.success).toBe(false)
})
})
Fake 提供了比 Stub 更豐富的行為,但也帶來了維護成本。當系統變複雜時,Fake 可能需要自己的測試套件
各框架術語差異
這裡是讓人最困惑的地方!各框架對這些術語的使用方式都不太一樣
總結對照表
| 原始術語 | 核心功能 | Mockito | PHPUnit | NSubstitute | Jest | Python (unittest.mock) |
|---|---|---|---|---|---|---|
| Dummy | 什麼都不做 | mock() | createMock() | Substitute.For<>() | jest.fn() | MagicMock() |
| Stub | 回傳特定值 | when().thenReturn() | willReturn() | .Returns() | .mockReturnValue() | .return_value |
| Spy | 記錄呼叫 | verify() | expects() | .Received() | expect().toHaveBeenCalled() | .assert_called_with() |
| Mock | 內建預期 | (框架自動處理) | (框架自動處理) | (框架自動處理) | (框架自動處理) | (框架自動處理) |
| Fake | 簡化實作 | (需手寫) | (需手寫) | (需手寫) | (需手寫) | (需手寫) |
Java - Mockito
// Mockito 的 "mock" 其實更像 Stub
Authenticator auth = mock(Authenticator.class);
when(auth.authenticate("bob", "pass")).thenReturn(true);
// Mockito 的 "spy" 是包裝真實物件
List<String> realList = new ArrayList<>();
List<String> spyList = spy(realList);
doReturn(100).when(spyList).size(); // 只覆寫 size()
Mockito 的 mock() 產生的物件預設行為是回傳 null/0/false,加上 when().thenReturn() 後才變成 Stub。而 spy() 是包裝真實物件,與原始定義的 Spy 差很多
PHP - PHPUnit
// PHPUnit 的命名相對清楚(PHP 8+)
$stub = $this->createStub(Authenticator::class);
$stub->method('authenticate')->willReturn(true);
// createMock 同時有 stub 和 verification 功能
$mock = $this->createMock(Authenticator::class);
$mock->expects($this->once()) // 這是 Spy 的驗證功能
->method('authenticate')
->willReturn(true); // 這是 Stub 功能
C# - NSubstitute
// NSubstitute 比較直觀
var auth = Substitute.For<IAuthenticator>();
// 當作 Stub 使用
auth.Authenticate("bob", "pass").Returns(true);
// 當作 Spy 使用(驗證呼叫)
auth.Received(1).Authenticate("bob", "pass");
NSubstitute 不區分「mock」和「stub」,統一用 Substitute,避免了術語混淆
JavaScript - Jest
// Jest 的 fn() 本質上是 Spy
const mockAuth = jest.fn()
mockAuth.mockReturnValue(true) // 加上這行變成 Stub
// 驗證呼叫(Spy 功能)
expect(mockAuth).toHaveBeenCalledWith('bob', 'pass')
Python - unittest.mock / pytest
# Python 的 unittest.mock 模組
from unittest.mock import Mock, MagicMock, patch
# 建立 Mock 物件(類似 Dummy/Stub)
auth = Mock()
auth.authenticate.return_value = True # Stub:設定回傳值
# 使用 patch 裝飾器替換依賴
@patch('module.Authenticator')
def test_login(mock_auth):
mock_auth.authenticate.return_value = True
# ... 測試程式碼
# 驗證呼叫(Spy 功能)
auth.authenticate.assert_called_once_with('bob', 'pass')
auth.authenticate.assert_called() # 確認有被呼叫
Python 的 unittest.mock 是標準函式庫的一部分,Mock 和 MagicMock 預設行為是回傳新的 Mock 物件。搭配 .return_value 設定後變成 Stub,使用 .assert_* 方法則提供 Spy 的驗證功能。pytest-mock 套件則提供更方便的 mocker fixture
實務上的簡化
自己帶團隊和相關教學測試的經驗,我發現大部分情況只需要記住兩件事
Stub:隔離第三方依賴
當你想讓測試不依賴外部系統,並且控制依賴的回傳值時用 Stub
// 隔離外部 API,控制它的回傳結果
paymentGateway.charge.mockReturnValue({ success: true })
// 讓測試不依賴真實的資料庫
userRepository.findById.mockReturnValue({ id: 1, name: 'Bob' })
這樣測試就能專注在自己的邏輯,不用管外部服務掛不掛
Mock:驗證第三方的呼叫
當你想確認程式有沒有正確呼叫依賴時用 Mock
// 驗證有呼叫寄信服務
expect(emailService.send).toHaveBeenCalled()
// 驗證呼叫參數是否正確
expect(emailService.send).toHaveBeenCalledWith('[email protected]', '歡迎加入')
有時候你不在乎依賴回傳什麼,只在乎「有沒有呼叫」和「參數對不對」
為什麼只需要這兩個
現代測試框架把五種 Test Double 的界線弄得很模糊了。實務上記住這樣就夠
- 想隔離依賴、控制回傳值 → Stub
- 想驗證有呼叫、參數正確 → Mock
Uncle Bob 的建議
《Clean Craftsmanship》書中提到幾個重要觀點
-
優先使用 Dummy 和 Stub:它們最簡單,也最不容易出錯
-
Spy 比 Mock 好:Uncle Bob 偏好在測試中明確寫出斷言,而不是藏在 Mock 裡
-
小心 Fake 的膨脹:Fake 容易越長越大,最後需要自己的測試
-
不要過度 mock:只有在真正需要隔離依賴時才使用 Test Double
什麼時候用哪一種
| 場景 | 建議使用 |
|---|---|
| 只需要滿足型別要求 | Dummy |
| 需要控制依賴的回傳值 | Stub |
| 需要驗證方法是否被呼叫、參數是否正確 | Spy |
| 預期複雜的互動序列(較少使用) | Mock |
| 需要一個簡化但真正運作的實作 | Fake |
結論
了解這五種類型後,至少在看到不同框架的 API 時不會一頭霧水。
Uncle Bob 的建議很實際:從最簡單的開始(Dummy、Stub),必要時再升級到 Spy,Mock 和 Fake 則要謹慎使用
下次當有人說「mock 一下」,你可以問他:「你是要 stub 回傳值,還是要驗證呼叫?」
圖片來源:AI 產生