| name | test-doubles |
| description | Test Doubles 最佳實踐指南,包含五種類型詳解與使用優先順序。當需要選擇 Mock/Stub/Fake/Spy/Dummy、或討論測試隔離策略時使用。 |
Test Doubles Best Practices Guide (2025-2026)
五種 Test Doubles
| 類型 | 定義 | 用途 |
|---|
| Dummy | 傳遞但從未使用的物件,填充參數列表 | 滿足型別要求 |
| Stub | 提供預定義(canned)回應 | 狀態驗證:檢查 SUT 的結果 |
| Spy | Stub + 記錄呼叫資訊 | 事後驗證互動 |
| Mock | 預設期望的呼叫,自身驗證失敗 | 行為驗證(最嚴格) |
| Fake | 完整但簡化的實作(不適合 production) | 有真實邏輯、維護狀態 |
優先順序(從高到低)
1. Real Implementation ← 最優先:快速、確定性、簡單依賴
2. Fake ← 次佳:忠實行為、維護狀態
3. Stub ← 回傳固定值:用於狀態驗證
4. Spy ← Stub + 呼叫記錄:需要驗證呼叫時
5. Mock ← 最後手段:嚴格行為驗證、最多耦合
決策樹
能用真實物件嗎?
├─ YES → 用真實實作
└─ NO → 依賴是否複雜且有狀態?
├─ YES → 寫 Fake
└─ NO → 需要驗證互動嗎?
├─ NO → 用 Stub(回傳固定資料)
└─ YES → 需要事前期望還是事後檢查?
├─ 事後 → 用 Spy
└─ 事前 → 用 Mock(最後手段)
何時用真實實作
- 依賴是 value object(金額、日期、集合)
- 快速(< 幾 ms)、確定性、無外部 I/O
- 純邏輯(validator、parser、formatter、calculator)
何時用 Test Doubles
- 網路 I/O(HTTP API、資料庫)
- 不確定性(時間、亂數、系統狀態)
- 緩慢(> 100ms)
- 副作用(寄信、扣款)
Vitest 對應
const dummyLogger: Logger = { log: () => {}, error: () => {} };
const getUser = vi.fn().mockReturnValue({ id: 1, name: 'Alice' });
const spy = vi.spyOn(userService, 'save');
expect(spy).toHaveBeenCalledWith({ id: 1, name: 'Alice' });
vi.mock('./emailService', () => ({
sendEmail: vi.fn().mockResolvedValue(true),
}));
class FakeUserRepository implements UserRepository {
private users = new Map<string, User>();
async findById(id: string) { return this.users.get(id) ?? null; }
async save(user: User) { this.users.set(user.id, user); }
}
偏好 vi.spyOn 而非 vi.mock
| vi.spyOn | vi.mock |
|---|
| 範圍 | 區域性,單一方法 | 全檔案,整個模組 |
| 型別安全 | 是 | 較弱 |
| 保留原始 | 預設保留 | 全部替換 |
| Hoisting | 不會 | 會被提升到檔案頂部 |
vi.mock 是 footgun — hoisting 行為和全檔案範圍讓測試難以推理。預設用 vi.spyOn。
Sociable vs Solitary Tests
Sociable Tests(社交型)
- 允許 SUT 與真實協作者互動
- Chicago/Classicist 風格
- 2025 共識:這是預設選擇
Solitary Tests(獨立型)
- 所有協作者替換為 test doubles
- London/Mockist 風格
- 只在邊界使用(外部服務、I/O)
Google 經驗:大量 mock 的獨立測試「需要持續維護但很少發現 bug」。
Anti-Patterns
Over-Mocking
一個測試需要 5+ mock → 設計有太多依賴,重構 production code。
Testing Implementation Details
expect(mockRepo.save).toHaveBeenCalledTimes(1);
const saved = await fakeRepo.findByName('Alice');
expect(saved).not.toBeNull();
Fragile Tests
每次重構都壞 → 測試耦合了內部細節而非行為。
Don't Mock What You Don't Own
不要直接 mock 第三方 API,而是:
- 建立自己的 wrapper/adapter
- Mock/Fake 你的 wrapper
- 用整合測試驗證 wrapper 與真實服務
vi.mock('node-fetch');
interface HttpClient {
get<T>(url: string): Promise<T>;
}
class FakeHttpClient implements HttpClient {
private responses = new Map<string, unknown>();
stubGet(url: string, data: unknown) { this.responses.set(url, data); }
async get<T>(url: string): Promise<T> {
return this.responses.get(url) as T;
}
}
Dependency Injection(TypeScript)
DI 是可測試性的最重要模式。不需要框架 — constructor injection + interface 就夠了:
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
class PostgresUserRepository implements UserRepository { }
class FakeUserRepository implements UserRepository {
private store = new Map<string, User>();
async findById(id: string) { return this.store.get(id) ?? null; }
async save(user: User) { this.store.set(user.id, user); }
}
class UserService {
constructor(private repo: UserRepository) {}
}
it('creates a user', async () => {
const repo = new FakeUserRepository();
const service = new UserService(repo);
const user = await service.createUser({ name: 'Alice' });
expect(await repo.findById(user.id)).toEqual(user);
});
測試外部服務
HTTP API
- 單元測試:Fake HTTP client 或 stub
- 整合測試:MSW(Mock Service Worker)攔截網路層
- 契約測試:Pact 驗證消費者期望
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.github.com/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Alice' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
資料庫
- 單元測試:FakeRepository(in-memory Map)
- 整合測試:Testcontainers 或 SQLite in-memory
檔案系統
- 單元測試:注入 FS 介面,提供 in-memory fake(memfs)
- 整合測試:temp directory
Fake vs Mock 比較
| 面向 | Fake | Mock |
|---|
| 忠實度 | 高 — 真實行為 | 低 — 固定回應 |
| 維護者 | 依賴的擁有者 | 每個測試作者 |
| 重構安全 | 耐重構 | 重構就壞 |
| 可讀性 | 像真實程式碼 | 像設定檔 |
| 初始成本 | 較高 | 較低 |
| 長期成本 | 較低(1 fake, N tests) | 較高(N mocks) |
Google 指導:對複雜依賴,Fake 優於 Mock。
契約測試
驗證 test doubles 忠實反映真實外部服務:
- Consumer 用 stub/fake 寫測試
- Stub 定義「契約」(預期 request/response)
- 契約對真實 provider 做驗證
- Provider 變更 → 契約測試失敗 → 更新 doubles
在 GitStar 專案的應用
| 元件 | 策略 |
|---|
| Country Config loader | Real — 純邏輯,快速 |
| GitHub API client | Fake HttpClient + MSW 整合測試 |
| Rate limiter | Fake timers(vi.useFakeTimers) |
| 排序/去重邏輯 | Real — 純函式 |
| Three.js renderer | Mock WebGLRenderer |
| OG image 產生 | Stub satori 回傳固定 SVG |
| File I/O | Fake FS 或 vi.mock('node:fs/promises') |