モックはコンポーネントを分離して独立してテストするために不可欠です。このガイドではJestとVitestを使用したReactテストでの一般的なモックパターンを解説します。
なぜモックするのか?
モックは以下の点で役立ちます:
- テスト対象のコードを分離する
- 依存関係の動作を制御する
- 遅いまたは信頼性の低い操作(ネットワーク、ファイルシステム)を回避する
- エッジケースやエラーシナリオをテストする
flowchart TD
A[テスト対象コンポーネント] --> B{外部依存}
B -->|実物| C[遅い/信頼性が低い]
B -->|モック| D[速い/制御可能]
style C fill:#ef4444,color:#fff
style D fill:#10b981,color:#fff
モジュールのモック
基本的なモジュールモック
// api.ts
export async function fetchUsers() {
const response = await fetch('/api/users');
return response.json();
}
// UserList.tsx
import { fetchUsers } from './api';
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetchUsers().then(setUsers);
}, []);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { UserList } from './UserList';
import { fetchUsers } from './api';
// モジュール全体をモック
jest.mock('./api');
// モック関数の型付け
const mockFetchUsers = fetchUsers as jest.MockedFunction<typeof fetchUsers>;
test('ユーザーを表示', async () => {
mockFetchUsers.mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
test('空のリストを処理', async () => {
mockFetchUsers.mockResolvedValue([]);
render(<UserList />);
// 空の状態をアサート
await waitFor(() => {
expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
});
});
部分的なモジュールモック
特定のエクスポートのみをモック:
// utils.ts
export function formatDate(date: Date) { /* ... */ }
export function formatCurrency(amount: number) { /* ... */ }
export function calculateTax(amount: number) { /* ... */ }
// formatDateだけモック、他は実物を保持
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
formatDate: jest.fn(() => '2024-01-01'),
}));
ファクトリーでのモック
jest.mock('./api', () => ({
fetchUsers: jest.fn(),
createUser: jest.fn(),
deleteUser: jest.fn(),
}));
コンポーネントのモック
子コンポーネントのモック
// Dashboard.tsx
import { HeavyChart } from './HeavyChart';
import { UserStats } from './UserStats';
function Dashboard({ userId }) {
return (
<div>
<h1>ダッシュボード</h1>
<UserStats userId={userId} />
<HeavyChart data={chartData} />
</div>
);
}
// Dashboard.test.tsx
// 子コンポーネントをモック
jest.mock('./HeavyChart', () => ({
HeavyChart: () => <div data-testid="mock-chart">チャート</div>,
}));
jest.mock('./UserStats', () => ({
UserStats: ({ userId }) => <div data-testid="mock-stats">{userId}の統計</div>,
}));
test('モックされた子でダッシュボードをレンダリング', () => {
render(<Dashboard userId={123} />);
expect(screen.getByText('ダッシュボード')).toBeInTheDocument();
expect(screen.getByTestId('mock-chart')).toBeInTheDocument();
expect(screen.getByTestId('mock-stats')).toHaveTextContent('123の統計');
});
Propsをキャプチャするモック
jest.mock('./UserStats', () => ({
UserStats: jest.fn(() => null),
}));
import { UserStats } from './UserStats';
test('UserStatsに正しいpropsを渡す', () => {
render(<Dashboard userId={123} />);
expect(UserStats).toHaveBeenCalledWith(
expect.objectContaining({ userId: 123 }),
expect.anything()
);
});
関数のスパイ
Spy vs Mock
flowchart LR
subgraph Spy["jest.spyOn"]
A[呼び出しを監視]
B[元の実装を保持]
C[オプションでオーバーライド]
end
subgraph Mock["jest.fn / jest.mock"]
D[完全に置き換え]
E[戻り値を制御]
F[元の動作なし]
end
style Spy fill:#3b82f6,color:#fff
style Mock fill:#10b981,color:#fff
スパイの使用
// オブジェクトメソッドをスパイ
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
test('失敗時にエラーをログ', async () => {
render(<ComponentThatMightError />);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Something went wrong');
});
consoleSpy.mockRestore();
});
モジュール関数のスパイ
import * as api from './api';
test('マウント時にfetchUsersを呼び出す', () => {
const spy = jest.spyOn(api, 'fetchUsers').mockResolvedValue([]);
render(<UserList />);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
モック関数(jest.fn)
基本的な使い方
test('onClickハンドラーを呼び出す', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>クリック</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
戻り値のモック
const mockFn = jest.fn();
// 戻り値
mockFn.mockReturnValue('hello');
// 一度だけ戻り値
mockFn.mockReturnValueOnce('first').mockReturnValueOnce('second');
// 非同期戻り値
mockFn.mockResolvedValue({ data: 'value' });
mockFn.mockRejectedValue(new Error('Failed'));
アサーション
// 呼び出された
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(3);
// 特定の引数で呼び出された
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('final');
expect(mockFn).toHaveBeenNthCalledWith(2, 'second call');
// 呼び出されていない
expect(mockFn).not.toHaveBeenCalled();
// すべての呼び出しを確認
expect(mockFn.mock.calls).toEqual([
['first call'],
['second call'],
['third call'],
]);
ブラウザAPIのモック
localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
test('localStorageに保存', () => {
render(<SettingsForm />);
// ... フォームと対話
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'settings',
expect.any(String)
);
});
window.matchMedia
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
test('ダークモードの設定を検出', () => {
window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
// ... 残りのモック
}));
render(<ThemeProvider><App /></ThemeProvider>);
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
IntersectionObserver
const mockIntersectionObserver = jest.fn();
mockIntersectionObserver.mockReturnValue({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
});
window.IntersectionObserver = mockIntersectionObserver;
test('画像を遅延ロード', () => {
render(<LazyImage src="photo.jpg" />);
expect(mockIntersectionObserver).toHaveBeenCalled();
});
外部ライブラリのモック
React Router
import { useNavigate, useParams } from 'react-router-dom';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
useParams: jest.fn(),
}));
test('送信時にナビゲート', async () => {
const navigate = jest.fn();
(useNavigate as jest.Mock).mockReturnValue(navigate);
(useParams as jest.Mock).mockReturnValue({ id: '123' });
const user = userEvent.setup();
render(<EditForm />);
await user.click(screen.getByRole('button', { name: '保存' }));
expect(navigate).toHaveBeenCalledWith('/success');
});
日付/時刻ライブラリ
// 現在日付をモック
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15'));
});
afterEach(() => {
jest.useRealTimers();
});
test('現在日付を表示', () => {
render(<DateDisplay />);
expect(screen.getByText('2024年1月15日')).toBeInTheDocument();
});
モックのクリアとリセット
個別のテストで
const mockFn = jest.fn();
afterEach(() => {
mockFn.mockClear(); // 呼び出し履歴をクリア
// または
mockFn.mockReset(); // クリア + 戻り値を削除
// または
mockFn.mockRestore(); // 元に戻す(スパイ用)
});
グローバル設定
// jest.config.js または vitest.config.ts
{
clearMocks: true, // テスト間でモック呼び出しをクリア
resetMocks: true, // テスト間でモック状態をリセット
restoreMocks: true, // 元の実装を復元
}
モックパターン
複雑なモック用のファクトリーパターン
// test-utils/mockUser.ts
export function createMockUser(overrides = {}) {
return {
id: 1,
name: 'テストユーザー',
email: 'test@example.com',
role: 'user',
...overrides,
};
}
// 使用例
test('管理者ユーザーに管理者バッジを表示', () => {
const adminUser = createMockUser({ role: 'admin' });
render(<UserProfile user={adminUser} />);
expect(screen.getByText('管理者')).toBeInTheDocument();
});
コンテキストモックヘルパー
// test-utils/renderWithProviders.tsx
export function renderWithAuth(ui, { user = null } = {}) {
return render(
<AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
{ui}
</AuthContext.Provider>
);
}
// 使用例
test('未認証時にログインボタンを表示', () => {
renderWithAuth(<Header />);
expect(screen.getByRole('button', { name: 'ログイン' })).toBeInTheDocument();
});
test('認証時にユーザー名を表示', () => {
renderWithAuth(<Header />, { user: { name: 'Alice' } });
expect(screen.getByText('Alice')).toBeInTheDocument();
});
ベストプラクティス
1. 過度にモックしない
// 悪い:すべてをモック
jest.mock('./utils');
jest.mock('./helpers');
jest.mock('./formatters');
// 良い:外部依存のみモック
jest.mock('./api'); // 外部APIコールのみ
2. 適切なレベルでモック
// 悪い:実装の詳細をモック
jest.mock('./useUserState'); // 内部フック
// 良い:外部境界でモック
jest.mock('./api'); // ネットワーク境界
3. モックを実際の動作に近づける
// 悪い:非現実的なモック
mockFetch.mockResolvedValue({ anything: 'goes' });
// 良い:APIの形状に一致
mockFetch.mockResolvedValue({
users: [{ id: 1, name: 'Alice', email: 'alice@example.com' }],
total: 1,
page: 1,
});
4. テスト間でモックをリセット
afterEach(() => {
jest.clearAllMocks();
});
まとめ
| テクニック | ユースケース |
|---|---|
jest.mock() |
モジュール全体をモック |
jest.spyOn() |
呼び出しを監視、オプションでモック |
jest.fn() |
モック関数を作成 |
mockReturnValue |
戻り値を設定 |
mockResolvedValue |
非同期戻り値を設定 |
mockImplementation |
カスタムモック動作 |
重要なポイント:
- 内部実装ではなく外部依存をモック
- 動作を変えずに監視したい場合はスパイを使用
- 複雑なデータ構造にはモックファクトリーを作成
- 汚染を避けるためテスト間でモックをクリア
- 統合の問題を発見するためモックを現実的に保つ
- APIモックには手動のfetchモックよりMSWを推奨
効果的なモックはテストをより速く、信頼性が高く、書きやすくします。アプリケーションの境界でモックすることに集中しましょう。
参考文献
- Jest Mock Functions
- Vitest Mocking
- Crump, Scottie. Simplify Testing with React Testing Library. Packt, 2021.
- Ruscio, Daniel. Testing JavaScript Applications. Manning Publications, 2021.