Reactテストの困りごとを解決: Vitest & Testing Libraryベストプラクティス
「ESMモジュールのモッキングがうまくいかない」「どのクエリを使うべきか迷う」そんなReactテストの困りごとを、vi.hoistedパターンとTesting Libraryのベストプラクティスで解決します。
ソフトウェアエンジニア
作成日:
更新日:

vi.fn() = Stub/Dummy/Mock、vi.spyOn() = Spy として対応。vi.mock() はモジュール置換機能vi.hoisted() を使うことでESMのホイスティング問題を解決できる「vi.mock() と vi.fn() の違いがよくわからない...」
「テストが壊れやすくて、ちょっとした変更で大量のテストが失敗する」
「モックって言葉、なんとなく使ってるけど正確な意味は...?」
こんな経験はありませんか?
実は、私たちが普段「モック」と呼んでいるものには、明確な分類があります。Martin Fowlerが紹介した「テストダブル」という概念を理解すると、これらの悩みがスッキリ解決します。
この記事では、テストダブルの5つの分類を学び、Vitest + React Testing Libraryでの具体的な実装パターンを紹介します。概念を理解することで、テストの設計力が格段に向上するはずです。

テストダブル(Test Double)とは、テスト目的で本番オブジェクトを置き換える代役オブジェクトの総称です。
この用語は、Gerard Meszarosの著書「xUnit Test Patterns」で提唱され、Martin Fowlerがブログ記事で広めました。「ダブル」は映画のスタントダブル(代役)に由来しています。
多くの開発者は「モック」という言葉を、テスト用の代替オブジェクト全般に使っています。しかし、実際には目的や振る舞いによって異なる種類があります。
分類を理解するメリット:
Martin Fowler(Gerard Meszarosの分類を引用)によると、テストダブルは以下の5種類に分類されます。
パラメータを埋めるためだけに使い、実際には呼ばれないオブジェクトです。
// 型定義を満たすためだけに渡す空の関数
const dummyLogger = {
log: () => {},
error: () => {},
warn: () => {},
};
// loggerは使われないが、コンストラクタが要求するので渡す
const service = new UserService(repository, dummyLogger);Dummyは最もシンプルなテストダブルです。テスト対象が依存オブジェクトを使わない場合に、型チェックを通すために使います。
実際に動作する実装を持つが、本番環境には適さないショートカットを使うオブジェクトです。
代表的な例:
// Fakeの例:インメモリで動作するリポジトリ
class InMemoryUserRepository implements UserRepository {
private users: Map<number, User> = new Map();
private nextId = 1;
async create(data: CreateUserInput): Promise<User> {
const user = { id: this.nextId++, ...data };
this.users.set(user.id, user);
return user;
}
async findById(id: number): Promise<User | null> {
return this.users.get(id) ?? null;
}
async findAll(): Promise<User[]> {
return Array.from(this.users.values());
}
}Fakeは他のテストダブルと異なり、実装ロジックを持ちます。テストDBを使った統合テストもFakeパターンの一種です。
Fakeはロジックを持つため、Fake自体にバグがあるとテスト結果が信頼できなくなります。そのため、Fakeに対するテストケースを書くことが重要です。
// Fake自体のテスト
describe('InMemoryUserRepository', () => {
it('ユーザーを作成して取得できる', async () => {
const repo = new InMemoryUserRepository();
const created = await repo.create({ name: 'Alice', email: 'alice@example.com' });
const found = await repo.findById(created.id);
expect(found).toEqual(created);
});
it('存在しないIDはnullを返す', async () => {
const repo = new InMemoryUserRepository();
const found = await repo.findById(999);
expect(found).toBeNull();
});
});Fakeのテストは、本番の実装と同じ振る舞いをすることを保証するために書きます。これにより、Fakeを使ったテストの信頼性が高まります。
テスト中の呼び出しに対して、あらかじめ決められた応答を返すオブジェクトです。プログラムされた内容以外には応答しません。
// Stubの例:固定値を返すリポジトリ
const stubRepository = {
findById: vi.fn().mockResolvedValue({
id: 1,
name: 'Test User',
email: 'test@example.com',
}),
};
// テスト対象はこの固定値を受け取る
const user = await userService.getUser(1);
expect(user.name).toBe('Test User');Stubは「何を返すか」を制御します。外部APIの応答をシミュレートしたり、特定の状態を再現したりするのに使います。
Stubとしての機能に加えて、呼び出し方法に基づいて情報を記録するオブジェクトです。
// Spyの例:既存の実装を監視
const consoleSpy = vi.spyOn(console, 'error');
await userService.deleteUser(999); // 存在しないID
// 呼び出しを検証
expect(consoleSpy).toHaveBeenCalledWith(
'User not found:',
999
);
consoleSpy.mockRestore();Spyは「どう呼ばれたか」を記録します。既存の実装を置き換えずに監視したい場合に使います。
期待値があらかじめプログラムされ、予期しない呼び出しで例外をスローできるオブジェクトです。
// Mockの例:期待値を検証
const mockMailer = {
send: vi.fn(),
};
await userService.register({
name: 'New User',
email: 'new@example.com',
});
// 期待通りに呼ばれたか検証
expect(mockMailer.send).toHaveBeenCalledWith({
to: 'new@example.com',
subject: 'ようこそ!',
body: expect.stringContaining('New User'),
});
expect(mockMailer.send).toHaveBeenCalledTimes(1);Mockは「期待通りに呼ばれたか」を検証します。これは重要な違いで、Stub/Fakeが「何を返すか(状態)」に注目するのに対し、Mockは「どう呼ばれたか(振る舞い)」に注目します。
| 種類 | 目的 | 実装 | 検証対象 |
|---|---|---|---|
| Dummy | 型を満たす | なし | - |
| Fake | 本番同等の動作 | あり | 状態 |
| Stub | 固定値を返す | 戻り値のみ | 状態 |
| Spy | 呼び出しを記録 | 既存 + 記録 | 呼び出し |
| Mock | 期待値を検証 | 戻り値 + 期待 | 呼び出し |
ここからは、Vitestでの具体的な実装パターンを見ていきます。
vi.fn() は最も基本的なテストダブル作成APIです。
import { describe, expect, it, vi } from 'vitest';
describe('UserService', () => {
it('リポジトリからユーザーを取得できる', async () => {
// Stub: 固定値を返すモック関数
const mockRepository = {
findById: vi.fn().mockResolvedValue({
id: 1,
name: 'Test User',
email: 'test@example.com',
}),
// Dummy: 型を満たすだけで呼ばれない
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
const service = new UserService(mockRepository);
const user = await service.getUser(1);
expect(user.name).toBe('Test User');
expect(mockRepository.findById).toHaveBeenCalledWith(1);
});
});// 同期的な戻り値
vi.fn().mockReturnValue('fixed value');
// 非同期の戻り値
vi.fn().mockResolvedValue({ data: 'async value' });
// エラーをスロー
vi.fn().mockRejectedValue(new Error('Failed'));
// カスタム実装
vi.fn().mockImplementation((id) => {
if (id === 1) return { id: 1, name: 'User 1' };
return null;
});
// 呼び出しごとに異なる値
vi.fn()
.mockResolvedValueOnce({ page: 1 })
.mockResolvedValueOnce({ page: 2 });vi.spyOn() は既存オブジェクトのメソッドを監視・部分的にモックします。
import { describe, expect, it, vi, afterEach } from 'vitest';
describe('ErrorHandler', () => {
afterEach(() => {
vi.restoreAllMocks(); // 重要:元の実装に戻す
});
it('エラー時にconsole.errorが呼ばれる', async () => {
// 既存の実装を監視(呼び出しを記録)
const consoleSpy = vi.spyOn(console, 'error')
.mockImplementation(() => {}); // 出力を抑制
const handler = new ErrorHandler();
handler.handle(new Error('Something went wrong'));
expect(consoleSpy).toHaveBeenCalledWith(
'[Error]',
expect.any(Error)
);
});
it('外部ライブラリのメソッドを監視', async () => {
const fetchSpy = vi.spyOn(global, 'fetch')
.mockResolvedValue(new Response('OK'));
await apiClient.get('/users');
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining('/users'),
expect.any(Object)
);
});
});| 項目 | vi.fn() | vi.spyOn() |
|---|---|---|
| 対象 | 新規作成 | 既存オブジェクト |
| 元の実装 | なし | 保持可能 |
| リストア | 不要 | mockRestore()必須 |
| 用途 | 依存の注入 | 既存の監視 |
| 項目 | vi.fn() | vi.spyOn() | vi.mock() |
|---|---|---|---|
| 対象 | 新規作成 | 既存オブジェクトのメソッド | モジュール全体 |
| 元の実装 | なし | 保持可能 | 置き換え |
| リストア | 不要 | mockRestore()必須 | 自動(テスト終了時) |
| 用途 | 依存の注入 | 既存の監視 | モジュールレベルの置換 |
| ホイスティング対応 | 不要 | 不要 | vi.hoisted()推奨 |
モジュール全体をモックする場合は vi.mock() を使います。
この問題、初めて遭遇したときは「なぜundefinedになる?」と混乱された方も多いのではないでしょうか。
ES Modulesでは、インポート文がファイルの先頭に巻き上げ(ホイスト)されます。そのため、以下のコードは動作しません:
// ❌ 動作しない例
import { userService } from './user-service';
const mockGetUser = vi.fn(); // インポートの後に定義される
vi.mock('./user-service', () => ({
userService: {
getUser: mockGetUser, // ReferenceError!
},
}));vi.hoisted() を使うと、モック定義がインポートより前に実行されます:
import { describe, expect, it, vi } from 'vitest';
// ✅ vi.hoisted()でモックを定義(推奨パターン)
const mocks = vi.hoisted(() => ({
userService: {
getUser: vi.fn(),
createUser: vi.fn(),
},
}));
// vi.mock()は自動的にホイストされる
vi.mock('./user-service', () => ({
userService: mocks.userService,
}));
// インポート(実際にはモックが使われる)
import { userController } from './user-controller';
describe('UserController', () => {
it('ユーザーを取得できる', async () => {
mocks.userService.getUser.mockResolvedValue({
id: 1,
name: 'Test User',
});
const result = await userController.get(1);
expect(result.name).toBe('Test User');
expect(mocks.userService.getUser).toHaveBeenCalledWith(1);
});
});統合テストでは、テスト用データベースを使ったFakeパターンが有効です:
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { dbClient } from './database';
import { UserRepository } from './user-repository';
describe('UserRepository', () => {
let repository: UserRepository;
beforeEach(async () => {
repository = new UserRepository(dbClient);
// テストデータをセットアップ
await dbClient('users').insert([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
});
afterEach(async () => {
// テストデータをクリーンアップ
await dbClient('users').del();
});
it('ユーザーを取得できる', async () => {
const user = await repository.findById(1);
expect(user).toEqual({
id: 1,
name: 'Alice',
email: 'alice@example.com',
});
});
it('存在しないユーザーはnullを返す', async () => {
const user = await repository.findById(999);
expect(user).toBeNull();
});
});Vitestでのモックの使い分けや、Testing Libraryのクエリ戦略については、以下の記事でさらに詳しく解説しています。
実際のReactコンポーネントテストでの使い方を見てみましょう。
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
// モックをホイスティング
const mocks = vi.hoisted(() => ({
api: {
users: {
list: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock('../api/client', () => ({
apiClient: mocks.api,
}));
import { UserListPage } from './UserListPage';
describe('UserListPage', () => {
it('ユーザー一覧が表示される', async () => {
// Stub: 固定のユーザーリストを返す
mocks.api.users.list.mockResolvedValue([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
render(<UserListPage />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
it('新規ユーザーを作成できる', async () => {
mocks.api.users.list.mockResolvedValue([]);
mocks.api.users.create.mockResolvedValue({
id: 1,
name: 'New User',
email: 'new@example.com',
});
render(<UserListPage />);
// フォームに入力
await userEvent.type(
screen.getByLabelText('名前'),
'New User'
);
await userEvent.type(
screen.getByLabelText('メール'),
'new@example.com'
);
// 送信
await userEvent.click(
screen.getByRole('button', { name: '作成' })
);
// Mock: 期待通りに呼ばれたか検証
await waitFor(() => {
expect(mocks.api.users.create).toHaveBeenCalledWith({
name: 'New User',
email: 'new@example.com',
});
});
});
});テストユーティリティを作成して、Providerのセットアップを共通化します:
// test-utils.tsx
import { render as originalRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export const render = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return originalRender(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
};
export * from '@testing-library/react';// 使用例
import { render, screen } from './test-utils';
it('コンポーネントがレンダリングされる', () => {
render(<UserProfile userId={1} />);
// Providerが自動的にセットアップされる
});レビューやペアプロで「ここはStubで固定値を返してる」「このSpyで呼び出し回数を確認してる」と具体的に伝えられるため、認識齟齬が減ります。
| シナリオ | 推奨 |
|---|---|
| 戻り値を制御したい | Stub |
| 呼び出しを確認したい | Spy / Mock |
| 実際のDB処理をテストしたい | Fake |
| 型を満たすだけ | Dummy |
「モックしすぎ」を避けることで、リファクタリング耐性が高まります。
// ❌ 過剰なモック:実装詳細に依存
vi.mock('./utils', () => ({
formatDate: vi.fn().mockReturnValue('2024-01-01'),
parseDate: vi.fn(),
validateDate: vi.fn().mockReturnValue(true),
}));
// ✅ 必要最小限のモック:振る舞いに注目
vi.mock('./api-client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
}));依存オブジェクトが必要?
├─ NO → Dummy(空実装)
└─ YES
└─ 実際の動作が必要?
├─ YES → Fake(テストDB等)
└─ NO
└─ 呼び出しを確認したい?
├─ NO → Stub(固定値を返す)
└─ YES
└─ 既存オブジェクトを使う?
├─ YES → Spy(vi.spyOn)
└─ NO → Mock(vi.fn + 検証)// ❌ テストごとにモックをリセットしない
// → 他のテストに影響する
// ✅ 各テスト後にリセット
afterEach(() => {
vi.resetAllMocks(); // モックの呼び出し履歴をクリア
vi.restoreAllMocks(); // vi.spyOn()の元の実装を復元
});毎回 afterEach を書くのは面倒なので、vitest.config.ts でグローバルに設定できます:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// 各テスト後に自動でモックをリセット
mockReset: true, // vi.resetAllMocks() と同等
restoreMocks: true, // vi.restoreAllMocks() と同等
// clearMocks: true, // vi.clearAllMocks() と同等(呼び出し履歴のみクリア)
},
});| オプション | 対応するAPI | 効果 |
|---|---|---|
clearMocks |
vi.clearAllMocks() |
呼び出し履歴をクリア |
mockReset |
vi.resetAllMocks() |
履歴クリア + 戻り値を undefined に |
restoreMocks |
vi.restoreAllMocks() |
vi.spyOn() の元の実装を復元 |
これにより、すべてのテストファイルで自動的にモックがリセットされ、テスト間の干渉を防げます。
// ❌ 実装詳細をモックする
vi.mock('./internal-helper', ...);
// ✅ 境界(API、DB)をモックする
vi.mock('./api-client', ...);この記事では、Martin Fowlerが紹介したテストダブルの5分類を、Vitestでの実装と組み合わせて解説しました。
「モック」という曖昧な言葉ではなく、Stub、Spy、Mockを使い分けることで、テストコードの意図が明確になります。また、vi.hoisted()のようなVitestの機能を理解することで、ESM時代のテスト実装がスムーズになるはずです。
テストは「書いて終わり」ではなく、保守し続けるものです。この記事で紹介したベストプラクティスを活用して、変化に強いテストコードを目指しましょう。
テストダブルの5つの分類を理解することで、Vitestでのテストがより明確になります。
| 種類 | Vitest API | 用途 |
|---|---|---|
| Dummy | vi.fn() |
型を満たすだけ |
| Stub | vi.fn().mockReturnValue() |
固定値を返す |
| Spy | vi.spyOn() |
既存を監視 |
| Mock | vi.fn() + 呼び出し検証 |
期待値を検証 |
| Fake | テストDB / インメモリ実装 | 簡易実装 |
※ vi.mock() はテストダブルの分類ではなく、モジュール全体を置き換える機能です。
覚えておくポイント:
テストダブルの概念を理解することで、「なぜこのテストが壊れやすいのか」「どこをモックすべきか」が明確になります。ぜひ日々のテスト実装に活かしてください。
お客様と一緒に課題を整理し、小さく始めて育てる「共創型開発」を行っています。
「こんなシステムは作れる?」「費用感を知りたい」など、どんな段階でもお気軽にご相談ください。
初回のご相談は無料です。
お問い合わせ