テストダブル完全ガイド:Vitest + React Testing Libraryでの実践
Martin Fowlerのテストダブル分類を基に、Vitest + React Testing Libraryでの実装パターンを解説。vi.fn()、vi.spyOn()、vi.mock()の使い分けとベストプラクティスを紹介します。

「ESMモジュールのモッキングがうまくいかない」「どのクエリを使うべきか迷う」そんなReactテストの困りごとを、vi.hoistedパターンとTesting Libraryのベストプラクティスで解決します。
React + Vitestでテストを書いていると、「vi.mockが効かない」「userEvent周りで不安定」「どのクエリを使えば良いか迷う」など、細かなつまずきが積み重なりがちです。
本記事では、実務でよく出会うこれらの課題を、Vitest & Testing Libraryのベストプラクティスに沿って整理します。特にvi.hoistedを使った安全なモック、アクセシブルなクエリ戦略、userEventの正しい使い方など、現場で役立つテクニックを中心に解説します。
内容は過去のプロジェクトで導入した経験と公式ドキュメントの説明に基づいています。
Vitestでモジュールをモックする際、vi.mock()はファイルの最上部に「ホイスティング」されます。これはimport文より先に評価される必要があるためです。
この仕組みにより、以下のコードは期待通りに動作しません。
// ❌ これは動作しない
import { describe, it, vi } from 'vitest';
const mockSignIn = vi.fn();
vi.mock('auth-library', () => ({
signIn: mockSignIn, // エラー: mockSignInは未定義
}));vi.mock()がホイストされた結果、mockSignInの定義より先に参照されてしまうからです。
[コード記述順] [実際の実行順(ホイスティング後)]
1. import 1. vi.mock() ← ここでmockSignInを参照しようとしてエラー
2. const mockSignIn 2. import
3. vi.mock() 3. const mockSignInこの問題を解決するのがvi.hoisted()です。vi.hoisted()内で定義された値も同様にホイストされるため、vi.mock()から安全に参照できます。
// ✅ vi.hoistedで正しくモック定義
import { describe, it, vi, expect } from 'vitest';
import { LoginForm } from './LoginForm';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const mocks = vi.hoisted(() => ({
auth: {
signIn: vi.fn(),
},
}));
vi.mock('auth-library', () => ({
signIn: mocks.auth.signIn,
}));
describe('LoginForm', () => {
it('ログインボタンクリックで認証関数が呼ばれる', async () => {
const user = userEvent.setup();
mocks.auth.signIn.mockResolvedValue({ success: true });
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(mocks.auth.signIn).toHaveBeenCalled();
});
});実際のプロジェクトでは、複数の依存モジュールをモックすることが多いでしょう。vi.hoisted()は単一のオブジェクトを返すため、関連するモックをまとめて定義できます。
const mocks = vi.hoisted(() => ({
userService: {
getCurrentUser: vi.fn(),
updateProfile: vi.fn(),
},
analyticsService: {
trackEvent: vi.fn(),
},
}));
vi.mock('./services/user', () => mocks.userService);
vi.mock('./services/analytics', () => mocks.analyticsService);テスト間でモックの状態が汚染されないよう、beforeEachでリセットすることを忘れずに。
beforeEach(() => {
vi.clearAllMocks();
});同じモジュールを複数のテストファイルでモックする場合、__mocks__ディレクトリにモックファイルを配置するManual Mockパターンも有効です。
src/
├── services/
│ └── api.ts
├── __mocks__/
│ └── services/
│ └── api.ts ← モック実装
└── components/
└── UserList.test.tsx// __mocks__/services/api.ts
export const fetchUsers = vi.fn().mockResolvedValue([
{ id: 1, name: 'テストユーザー' },
]);// UserList.test.tsx
vi.mock('./services/api'); // __mocks__内の実装が自動的に使われる
it('ユーザー一覧を表示する', async () => {
render(<UserList />);
expect(await screen.findByText('テストユーザー')).toBeInTheDocument();
});Manual Mockは、外部ライブラリ(axiosなど)や共通サービスのモックを一元管理したい場合に便利です。ただし、テストごとに異なる挙動が必要な場合はvi.hoistedパターンの方が柔軟に対応できます。
なお、vi.fn()とvi.spyOn()の使い分けや、Stub・Spy・Mockといったテストダブルの概念について詳しく知りたい方は、以下の記事も参考にしてください。vi.mock()はテストダブルそのものではなく、モジュール全体をモックに置き換える機能です。
Testing Libraryは「ユーザーがどのように要素を見つけるか」に基づいたクエリの優先順位を定めています。
| 優先度 | クエリ | 用途 |
|---|---|---|
| 1 | getByRole |
アクセシビリティツリーに公開された要素 |
| 2 | getByLabelText |
フォーム要素(ラベルとの関連) |
| 3 | getByPlaceholderText |
プレースホルダーで識別 |
| 4 | getByText |
テキストコンテンツで識別 |
| 5 | getByDisplayValue |
フォームの現在値で識別 |
| 6 | getByAltText / getByTitle |
セマンティック属性 |
| 7 | getByTestId |
最終手段 |
getByRoleはアクセシビリティツリーを参照します。これはスクリーンリーダーなどの支援技術がページを解釈する方法と同じです。つまり、getByRoleでテストを書くことは、アプリケーションのアクセシビリティを同時に検証していることになります。
import { render, screen, within } from '@testing-library/react';
import { RegistrationForm } from './RegistrationForm';
describe('RegistrationForm', () => {
it('フォーム要素をアクセシビリティに配慮して取得', () => {
render(<RegistrationForm />);
// Role + accessible name で特定
const emailInput = screen.getByRole('textbox', { name: 'メールアドレス' });
const submitButton = screen.getByRole('button', { name: '登録する' });
expect(emailInput).toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
});
it('within()でスコープを限定', () => {
render(<RegistrationForm />);
// 特定のセクション内に検索範囲を限定
const personalSection = screen.getByRole('region', { name: '個人情報' });
const nameInput = within(personalSection).getByRole('textbox', { name: '氏名' });
expect(nameInput).toBeInTheDocument();
});
});要素が存在しないことを確認する場合は、queryBy*を使用します。getBy*は要素が見つからないとエラーをスローしますが、queryBy*はnullを返すため、否定アサーションに適しています。
it('エラーメッセージが初期状態で非表示', () => {
render(<RegistrationForm />);
// queryByは見つからない場合nullを返す(エラーにならない)
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});Testing Libraryには2つのイベント発火方法があります。
| fireEvent | userEvent | |
|---|---|---|
| 発火するイベント | 単一イベント | 関連する複数イベント |
| 挙動 | 低レベル、直接的 | 実際のユーザー操作を模倣 |
| 例: クリック | click イベントのみ | pointerdown → pointerup → click |
| 可視性チェック | なし | あり(隠れた要素はクリック不可) |
| 非同期 | 同期的 | 非同期(await必須) |
userEventを推奨します。実際のブラウザ動作により近いテストが書け、バグの検出率が向上します。
重要: userEvent.setup()はコンポーネントのrender前に呼び出すことが推奨されています。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
it('ログインボタンをクリックするとsignInが呼ばれる', async () => {
// ✅ render前にsetup
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(mockSignIn).toHaveBeenCalledWith('auth0');
});
});describe内で一度だけsetupして使い回すパターンも有効です:
describe('FilterSection', () => {
const user = userEvent.setup();
const mockOnFilterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('絞り込みボタンをクリックできる', async () => {
render(<FilterSection onFilterChange={mockOnFilterChange} />);
await user.click(screen.getByRole('button', { name: '絞り込む' }));
expect(mockOnFilterChange).toHaveBeenCalled();
});
});// ボタンのクリック
await user.click(screen.getByRole('button', { name: '送信' }));
// チェックボックスの操作
await user.click(screen.getByLabelText('利用規約に同意する'));
// セレクトボックス(UIライブラリの場合)
const roleSelect = screen.getByLabelText('ロール');
await user.click(roleSelect); // セレクトを開く
await user.click(screen.getByTitle('管理者')); // オプションを選択// テキスト入力
await user.type(screen.getByLabelText('名前'), 'テストユーザー');
await user.type(screen.getByLabelText('メールアドレス'), 'test@example.com');
// パスワード入力
await user.type(screen.getByLabelText('パスワード'), 'SecurePass123!');
// textarea入力
const textarea = screen.getByPlaceholderText('備考を入力してください');
await user.type(textarea, '新しい備考内容');const input = screen.getByLabelText('名前');
await user.type(input, '初期値');
// 入力値をクリアしてから新しい値を入力
await user.clear(input);
await user.type(input, '新しい値');import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('FileUploader', () => {
it('ファイルをアップロードできる', async () => {
const user = userEvent.setup();
const mockOnUpload = vi.fn();
render(<FileUploader onUpload={mockOnUpload} />);
// Fileオブジェクトを作成
const file = new File(
['{"type":"FeatureCollection","features":[]}'],
'data.geojson',
{ type: 'application/json' }
);
// input[type="file"]を取得
const input = screen.getByLabelText('ファイルを選択');
// ファイルをアップロード
await user.upload(input, file);
// UIにファイル名が表示されることを確認
await waitFor(() => {
expect(screen.getByText('data.geojson')).toBeInTheDocument();
});
});
});バリデーションをトリガーするためにフォーカスを外す場合に便利です:
it('無効なメールアドレスでエラーが表示される', async () => {
const user = userEvent.setup();
render(<RegistrationForm />);
const emailInput = screen.getByLabelText('メールアドレス');
await user.type(emailInput, 'invalid-email');
// フォーカスを外してバリデーションをトリガー
await user.tab();
// エラーメッセージが表示されることを確認
expect(screen.getByText('有効なメールアドレスを入力してください')).toBeInTheDocument();
});大量のテキストを入力する場合、type()より高速です:
it('長文をペーストできる', async () => {
const user = userEvent.setup();
render(<TextEditor />);
const textarea = screen.getByRole('textbox');
// 5000文字のテキストをペースト
const longText = 'あ'.repeat(5000);
await user.paste(longText);
expect(textarea).toHaveValue(longText);
});React 19では状態更新がバッチ処理されるため、非同期テストの書き方がより重要です。
describe('FileUploader', () => {
it('ファイル選択後にファイル名が表示される', async () => {
const user = userEvent.setup();
render(<FileUploader onUpload={vi.fn()} />);
const file = new File(['content'], 'document.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText('ファイルを選択');
await user.upload(input, file);
// waitForで非同期更新の完了を待機
await waitFor(() => {
expect(screen.getByText('document.pdf')).toBeInTheDocument();
});
});
it('findByで要素の出現を待機', async () => {
const user = userEvent.setup();
render(<FileUploader onUpload={vi.fn()} />);
await user.click(screen.getByRole('button', { name: 'アップロード' }));
// findBy = waitFor + getBy のショートカット
const successMessage = await screen.findByRole('alert');
expect(successMessage).toHaveTextContent('アップロード完了');
});
});// findBy: 単一要素の出現を待つ
const element = await screen.findByRole('button', { name: '送信' });
// waitFor: 複雑な条件を待つ
await waitFor(() => {
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(screen.getByText('完了')).toBeInTheDocument();
});Reactアプリケーションでは、Context Providerが複数ネストすることが一般的です。テストごとにこれらを手動でラップするのは非効率です。
// test-utils.tsx
import { render as originalRender, RenderOptions } from '@testing-library/react';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React, { ReactElement } from 'react';
// テスト用のQueryClientを作成
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// カスタムrender関数
export const render = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => {
const queryClient = createTestQueryClient();
return originalRender(ui, {
...options,
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
),
});
};
// testing-libraryの全エクスポートを再エクスポート
export * from '@testing-library/react';// テストファイル
import { render, screen } from './test-utils'; // カスタムtest-utilsからインポート
import { Dashboard } from './Dashboard';
describe('Dashboard', () => {
it('必要なProviderがラップされた状態でレンダリング', () => {
render(<Dashboard />);
// Providerを意識せずにテスト可能
expect(screen.getByRole('heading', { name: 'ダッシュボード' })).toBeInTheDocument();
});
});テストでよく遭遇する問題とその解決方法をまとめました。
// エラー: Unable to find an element with the role "button" and name "送信"
// デバッグ: 利用可能なroleを確認
screen.debug(); // DOM全体を出力
// Testing Playgroundで確認
screen.logTestingPlaygroundURL(); // ブラウザで開けるURLを出力よくある原因:
nameオプションの大文字小文字が一致していないfindBy*を使うべき)// タイムアウトを延長する
await waitFor(
() => {
expect(screen.getByText('完了')).toBeInTheDocument();
},
{ timeout: 5000 } // デフォルト1000msを延長
);// 各テスト前にモックをリセット
beforeEach(() => {
vi.clearAllMocks(); // 呼び出し履歴をクリア
// vi.resetAllMocks(); // 実装もリセットする場合
});
// 特定のモックのみリセット
afterEach(() => {
mocks.userService.getCurrentUser.mockReset();
});Reactテストで悩まないための3つのキーポイント:
これらのベストプラクティスを適用することで、保守しやすく、信頼性の高いテストスイートを構築できます。
次のステップとして、あなたのプロジェクトでtest-utils.tsxを作成し、カスタムrender関数を導入してみてください。テストの記述量が減り、可読性が向上することを実感できるはずです。
また、「単体テストと結合テスト、どちらをどれくらい書くべきか?」というテスト戦略の全体像については、以下の記事で詳しく解説しています。