# Test Doubles

> Test Doubles 最佳實踐指南，包含五種類型詳解與使用優先順序。當需要選擇 Mock/Stub/Fake/Spy/Dummy、或討論測試隔離策略時使用。

- Skill: `recca0120/test-doubles` (Agent Skill)
- Install (CLI): `npx skillmds@latest add recca0120/test-doubles`
- Raw SKILL.md: https://api.skillmd.com/api/skills/recca0120/test-doubles/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: recca0120 (https://skillmd.com/u/recca0120)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/recca0120/test-doubles

---


# 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 對應

```typescript
// DUMMY — 填充參數
const dummyLogger: Logger = { log: () => {}, error: () => {} };

// STUB — vi.fn() 回傳固定值
const getUser = vi.fn().mockReturnValue({ id: 1, name: 'Alice' });

// SPY — 觀察真實行為
const spy = vi.spyOn(userService, 'save');
// ... 執行程式碼 ...
expect(spy).toHaveBeenCalledWith({ id: 1, name: 'Alice' });

// MOCK — 替換整個模組（vi.mock 會被 hoisted）
vi.mock('./emailService', () => ({
  sendEmail: vi.fn().mockResolvedValue(true),
}));

// FAKE — 真實運作的簡化實作
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
```typescript
// 壞 — 耦合實作
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，而是：

1. 建立自己的 **wrapper/adapter**
2. Mock/Fake **你的 wrapper**
3. 用**整合測試**驗證 wrapper 與真實服務

```typescript
// 壞 — 直接 mock fetch
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 就夠了：

```typescript
// 1. 定義介面
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

// 2. Production 實作
class PostgresUserRepository implements UserRepository { /* SQL */ }

// 3. Fake
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); }
}

// 4. Service 接受介面
class UserService {
  constructor(private repo: UserRepository) {}
}

// 5. 測試 — 注入 fake
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 驗證消費者期望

```typescript
// MSW
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 忠實反映真實外部服務：

1. Consumer 用 stub/fake 寫測試
2. Stub 定義「契約」（預期 request/response）
3. 契約對真實 provider 做驗證
4. 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') |

