Day 4: モック・スタブ・スパイ
今日学ぶこと
- テストダブルとは何か(モック、スタブ、スパイの違い)
jest.fn()でモック関数を作成する- モック関数の戻り値と実装を制御する
jest.spyOn()で既存メソッドを監視するjest.mock()でモジュール全体をモックする- モックのリセットとクリーンアップ
テストダブルとは
ユニットテストでは、テスト対象の関数が外部のモジュール(API、データベース、ファイルシステムなど)に依存していることがあります。これらの外部依存をテスト用の代役に置き換えることをテストダブルと呼びます。
flowchart TB
subgraph Real["本番コード"]
CODE["関数"]
API["外部API"]
DB["データベース"]
FS["ファイルシステム"]
end
subgraph Test["テストコード"]
TCODE["関数"]
MOCK_API["モックAPI"]
MOCK_DB["モックDB"]
MOCK_FS["モックFS"]
end
CODE --> API
CODE --> DB
CODE --> FS
TCODE --> MOCK_API
TCODE --> MOCK_DB
TCODE --> MOCK_FS
style Real fill:#ef4444,color:#fff
style Test fill:#22c55e,color:#fff
テストダブルの種類
| 種類 | 目的 | Jestでの実現方法 |
|---|---|---|
| スタブ(Stub) | 固定の値を返す代役 | jest.fn().mockReturnValue() |
| モック(Mock) | 呼び出しを記録し検証する代役 | jest.fn() |
| スパイ(Spy) | 本物の実装を維持しつつ呼び出しを監視 | jest.spyOn() |
flowchart LR
subgraph Doubles["テストダブルの種類"]
STUB["スタブ\n固定値を返す"]
MOCK["モック\n呼び出しを記録"]
SPY["スパイ\n本物を監視"]
end
STUB -->|"戻り値の制御"| U1["入力に対する\n出力をテスト"]
MOCK -->|"呼び出しの検証"| U2["正しく呼ばれたか\nをテスト"]
SPY -->|"元の実装を保持"| U3["副作用を\n監視してテスト"]
style STUB fill:#3b82f6,color:#fff
style MOCK fill:#8b5cf6,color:#fff
style SPY fill:#22c55e,color:#fff
jest.fn() — モック関数の基本
jest.fn() はモック関数を作成します。呼び出し回数、引数、戻り値をすべて記録します。
test('jest.fn() creates a mock function', () => {
const mockFn = jest.fn();
mockFn('hello');
mockFn('world');
// called twice
expect(mockFn).toHaveBeenCalledTimes(2);
// called with specific arguments
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenCalledWith('world');
// last called with
expect(mockFn).toHaveBeenLastCalledWith('world');
});
TypeScript版:
test('jest.fn() creates a mock function', () => {
const mockFn = jest.fn<void, [string]>();
mockFn('hello');
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
});
モック関数のマッチャー
| マッチャー | 説明 |
|---|---|
toHaveBeenCalled() |
1回以上呼ばれた |
toHaveBeenCalledTimes(n) |
n回呼ばれた |
toHaveBeenCalledWith(arg1, arg2, ...) |
特定の引数で呼ばれた |
toHaveBeenLastCalledWith(arg1, ...) |
最後の呼び出しの引数 |
toHaveBeenNthCalledWith(n, arg1, ...) |
n回目の呼び出しの引数 |
toHaveReturned() |
正常にreturnした |
toHaveReturnedWith(value) |
特定の値をreturnした |
戻り値の制御
mockReturnValue — 固定の戻り値
test('mockReturnValue returns a fixed value', () => {
const getPrice = jest.fn().mockReturnValue(100);
expect(getPrice()).toBe(100);
expect(getPrice()).toBe(100); // always returns 100
});
mockReturnValueOnce — 1回だけの戻り値
test('mockReturnValueOnce returns different values per call', () => {
const random = jest.fn()
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValueOnce(3);
expect(random()).toBe(1);
expect(random()).toBe(2);
expect(random()).toBe(3);
expect(random()).toBeUndefined(); // no more mocked values
});
mockResolvedValue — Promiseの戻り値
非同期関数のモックには mockResolvedValue を使います。
test('mockResolvedValue returns a resolved promise', async () => {
const fetchUser = jest.fn().mockResolvedValue({ name: 'Alice', age: 25 });
const user = await fetchUser();
expect(user).toEqual({ name: 'Alice', age: 25 });
expect(fetchUser).toHaveBeenCalledTimes(1);
});
test('mockRejectedValue returns a rejected promise', async () => {
const fetchUser = jest.fn().mockRejectedValue(new Error('Network error'));
await expect(fetchUser()).rejects.toThrow('Network error');
});
TypeScript版:
interface User {
name: string;
age: number;
}
test('mockResolvedValue returns a resolved promise', async () => {
const fetchUser = jest.fn<Promise<User>, []>()
.mockResolvedValue({ name: 'Alice', age: 25 });
const user = await fetchUser();
expect(user).toEqual({ name: 'Alice', age: 25 });
});
| メソッド | 用途 |
|---|---|
mockReturnValue(val) |
常に固定値を返す |
mockReturnValueOnce(val) |
1回だけ特定の値を返す |
mockResolvedValue(val) |
常にresolvedなPromiseを返す |
mockResolvedValueOnce(val) |
1回だけresolvedなPromiseを返す |
mockRejectedValue(err) |
常にrejectedなPromiseを返す |
mockRejectedValueOnce(err) |
1回だけrejectedなPromiseを返す |
mockImplementation — カスタム実装
より複雑なモックが必要な場合は mockImplementation を使います。
test('mockImplementation provides custom behavior', () => {
const add = jest.fn().mockImplementation((a, b) => a + b);
expect(add(1, 2)).toBe(3);
expect(add(10, 20)).toBe(30);
});
// shorthand: pass implementation to jest.fn()
test('jest.fn() accepts an implementation directly', () => {
const add = jest.fn((a, b) => a + b);
expect(add(1, 2)).toBe(3);
});
実践例: コールバックのテスト
モック関数はコールバックのテストに最適です。
// forEach.js
function forEach(items, callback) {
for (let i = 0; i < items.length; i++) {
callback(items[i], i);
}
}
module.exports = forEach;
TypeScript版:
// forEach.ts
export function forEach<T>(items: T[], callback: (item: T, index: number) => void): void {
for (let i = 0; i < items.length; i++) {
callback(items[i], i);
}
}
// forEach.test.js
const forEach = require('./forEach');
test('calls callback for each item', () => {
const mockCallback = jest.fn();
forEach(['a', 'b', 'c'], mockCallback);
// called 3 times
expect(mockCallback).toHaveBeenCalledTimes(3);
// check arguments for each call
expect(mockCallback).toHaveBeenNthCalledWith(1, 'a', 0);
expect(mockCallback).toHaveBeenNthCalledWith(2, 'b', 1);
expect(mockCallback).toHaveBeenNthCalledWith(3, 'c', 2);
});
jest.spyOn() — 既存メソッドの監視
jest.spyOn() は既存のオブジェクトのメソッドを監視します。元の実装は保持されるため、実際の動作を変えずに呼び出し情報を記録できます。
// calculator.js
const calculator = {
add(a, b) {
return a + b;
},
subtract(a, b) {
return a - b;
},
};
module.exports = calculator;
// calculator.test.js
const calculator = require('./calculator');
test('spyOn tracks calls without changing behavior', () => {
const spy = jest.spyOn(calculator, 'add');
const result = calculator.add(1, 2);
expect(result).toBe(3); // original implementation
expect(spy).toHaveBeenCalledWith(1, 2);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore(); // restore original
});
spyOn で戻り値を上書き
test('spyOn can override return value', () => {
const spy = jest.spyOn(calculator, 'add').mockReturnValue(999);
const result = calculator.add(1, 2);
expect(result).toBe(999); // overridden
expect(spy).toHaveBeenCalledWith(1, 2);
spy.mockRestore();
});
console.log のスパイ
test('spy on console.log', () => {
const spy = jest.spyOn(console, 'log').mockImplementation();
console.log('hello');
console.log('world');
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('hello');
spy.mockRestore();
});
TypeScript版:
test('spy on console.log', () => {
const spy = jest.spyOn(console, 'log').mockImplementation();
console.log('hello');
expect(spy).toHaveBeenCalledWith('hello');
spy.mockRestore();
});
| jest.fn() vs jest.spyOn() | jest.fn() | jest.spyOn() |
|---|---|---|
| 対象 | 新しい関数を作成 | 既存のメソッドを監視 |
| 元の実装 | なし(デフォルトはundefined返す) | 保持される |
| 復元 | 不要 | mockRestore() で復元 |
| 用途 | コールバック、依存関数の代役 | 既存コードの監視 |
jest.mock() — モジュール全体のモック
jest.mock() はモジュール全体をモックに置き換えます。
// userService.js
const axios = require('axios');
async function getUser(id) {
const response = await axios.get(`https://api.example.com/users/${id}`);
return response.data;
}
module.exports = { getUser };
TypeScript版:
// userService.ts
import axios from 'axios';
export interface User {
id: number;
name: string;
email: string;
}
export async function getUser(id: number): Promise<User> {
const response = await axios.get<User>(`https://api.example.com/users/${id}`);
return response.data;
}
// userService.test.js
const axios = require('axios');
const { getUser } = require('./userService');
jest.mock('axios');
describe('getUser', () => {
test('fetches user data from API', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
axios.get.mockResolvedValue({ data: mockUser });
const user = await getUser(1);
expect(user).toEqual(mockUser);
expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
expect(axios.get).toHaveBeenCalledTimes(1);
});
test('handles API error', async () => {
axios.get.mockRejectedValue(new Error('Network error'));
await expect(getUser(1)).rejects.toThrow('Network error');
});
});
TypeScript版のテスト:
// userService.test.ts
import axios from 'axios';
import { getUser } from './userService';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('getUser', () => {
test('fetches user data from API', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
mockedAxios.get.mockResolvedValue({ data: mockUser });
const user = await getUser(1);
expect(user).toEqual(mockUser);
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://api.example.com/users/1'
);
});
});
flowchart TB
subgraph Without["モックなし"]
T1["テスト"] --> S1["userService"] --> A1["axios"] --> API["外部API\n(不安定)"]
end
subgraph With["モックあり"]
T2["テスト"] --> S2["userService"] --> A2["axios(モック)\n固定レスポンス"]
end
style Without fill:#ef4444,color:#fff
style With fill:#22c55e,color:#fff
ファクトリ関数によるモック
jest.mock() の第2引数でモックの実装を指定できます。
jest.mock('./logger', () => ({
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
モックのリセットとクリーンアップ
テスト間でモックの状態をリセットすることが重要です。
describe('mock cleanup', () => {
const mockFn = jest.fn();
afterEach(() => {
mockFn.mockClear(); // or mockReset() or mockRestore()
});
test('first test', () => {
mockFn('hello');
expect(mockFn).toHaveBeenCalledTimes(1);
});
test('second test starts fresh', () => {
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(1); // not 2
});
});
| メソッド | リセット内容 |
|---|---|
mockClear() |
呼び出し記録をクリア(実装は維持) |
mockReset() |
呼び出し記録 + 実装をリセット(undefinedを返す) |
mockRestore() |
元の実装を復元(spyOn で使う) |
flowchart LR
subgraph Clear["mockClear()"]
C1["呼び出し記録 → クリア"]
C2["実装 → 維持"]
end
subgraph Reset["mockReset()"]
R1["呼び出し記録 → クリア"]
R2["実装 → リセット"]
end
subgraph Restore["mockRestore()"]
RE1["呼び出し記録 → クリア"]
RE2["実装 → 元に戻す"]
end
style Clear fill:#3b82f6,color:#fff
style Reset fill:#f59e0b,color:#fff
style Restore fill:#22c55e,color:#fff
ベストプラクティス:
jest.config.jsでclearMocks: trueを設定すると、各テスト後に自動的にmockClear()が実行されます。
// jest.config.js
module.exports = {
clearMocks: true,
};
まとめ
| 概念 | 説明 |
|---|---|
| テストダブル | 外部依存をテスト用の代役に置き換える手法 |
jest.fn() |
モック関数を作成。呼び出しを記録 |
mockReturnValue |
モックの戻り値を設定 |
mockResolvedValue |
モックがPromiseを返すよう設定 |
mockImplementation |
モックにカスタム実装を設定 |
jest.spyOn() |
既存メソッドを監視(元の実装を保持) |
jest.mock() |
モジュール全体をモックに置換 |
mockClear / mockReset / mockRestore |
モックの状態をリセット |
重要ポイント
jest.fn()でコールバックや依存関数をモック化できるjest.spyOn()は元の実装を保持しつつ監視するjest.mock()でモジュール全体を置き換えて外部依存を排除できる- テスト間のモック状態のリセットを忘れずに
練習問題
問題1: 基本
以下の notifyUsers 関数のテストを書いてください。sendEmail はモック関数として渡します。
function notifyUsers(users, sendEmail) {
users.forEach(user => {
sendEmail(user.email, `Hello, ${user.name}!`);
});
}
問題2: 応用
以下の fetchAndSave 関数のテストを jest.mock() を使って書いてください。
const api = require('./api');
const db = require('./db');
async function fetchAndSave(id) {
const data = await api.fetch(id);
await db.save(data);
return data;
}
チャレンジ問題
jest.spyOn() を使って、Math.random() を制御するテストを書いてください。Math.random() が常に 0.5 を返すようにして、以下の関数をテストしましょう。
function getRandomItem(arr) {
const index = Math.floor(Math.random() * arr.length);
return arr[index];
}
参考リンク
次回予告: Day 5では「非同期コードのテスト」について学びます。async/await、Promise、タイマー(setTimeout/setInterval)のテスト方法を詳しく見ていきましょう!