TL;DR
- vi.hoistedでESMモジュールのモッキング問題を解決(ホイスティングエラーを回避)
- getByRoleを第一選択にしてアクセシビリティと堅牢性を両立(スクリーンリーダー対応も同時に検証)
- userEventで実際のユーザー操作を忠実に再現(fireEventより多くのイベントを正しい順序で発火)
- カスタムtest-utilsでProviderラッピングを共通化(テストコードの重複を削減)
はじめに
React + Vitestでテストを書いていると、「vi.mockが効かない」「userEvent周りで不安定」「どのクエリを使えば良いか迷う」など、細かなつまずきが積み重なりがちです。
本記事では、実務でよく出会うこれらの課題を、Vitest & Testing Libraryのベストプラクティスに沿って整理します。特にvi.hoistedを使った安全なモック、アクセシブルなクエリ戦略、userEventの正しい使い方など、現場で役立つテクニックを中心に解説します。
内容は過去のプロジェクトで導入した経験と公式ドキュメントの説明に基づいています。
この記事の対象読者
- React + TypeScriptでの開発経験がある方
- Vitest/Jestでの基本的なテスト記述経験がある方
- ESMモジュールの概念を知っている方(またはこれから学びたい方)
動作環境
- React 19+
- Vitest 1.0+
- @testing-library/react 16+
- @testing-library/user-event 14+
- TypeScript 5.0+
vi.hoistedで安全なモックを実現する
問題: vi.mockのホイスティング制限
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.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();
});Manual Mock(__mocks__ディレクトリ)
同じモジュールを複数のテストファイルでモックする場合、__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パターンの方が柔軟に対応できます。
アクセシビリティ重視のクエリ戦略
Testing Libraryの推奨優先順位
Testing Libraryは「ユーザーがどのように要素を見つけるか」に基づいたクエリの優先順位を定めています。
| 優先度 | クエリ | 用途 |
|---|---|---|
| 1 | getByRole |
アクセシビリティツリーに公開された要素 |
| 2 | getByLabelText |
フォーム要素(ラベルとの関連) |
| 3 | getByPlaceholderText |
プレースホルダーで識別 |
| 4 | getByText |
テキストコンテンツで識別 |
| 5 | getByDisplayValue |
フォームの現在値で識別 |
| 6 | getByAltText / getByTitle |
セマンティック属性 |
| 7 | getByTestId |
最終手段 |
なぜgetByRoleを第一選択にすべきか
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* で存在しないことを確認
要素が存在しないことを確認する場合は、queryBy*を使用します。getBy*は要素が見つからないとエラーをスローしますが、queryBy*はnullを返すため、否定アサーションに適しています。
it('エラーメッセージが初期状態で非表示', () => {
render(<RegistrationForm />);
// queryByは見つからない場合nullを返す(エラーにならない)
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});ユーザー中心のインタラクションテスト: userEvent完全ガイド
fireEvent vs userEvent
Testing Libraryには2つのイベント発火方法があります。
| fireEvent | userEvent | |
|---|---|---|
| 発火するイベント | 単一イベント | 関連する複数イベント |
| 挙動 | 低レベル、直接的 | 実際のユーザー操作を模倣 |
| 例: クリック | click イベントのみ | pointerdown → pointerup → click |
| 可視性チェック | なし | あり(隠れた要素はクリック不可) |
| 非同期 | 同期的 | 非同期(await必須) |
userEventを推奨します。実際のブラウザ動作により近いテストが書け、バグの検出率が向上します。
userEvent.setup()の正しい使い方
重要: 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();
});
});主要なuserEventメソッド
user.click() - クリック操作
// ボタンのクリック
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('管理者')); // オプションを選択user.type() - テキスト入力
// テキスト入力
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, '新しい備考内容');user.clear() - 入力値のクリア
const input = screen.getByLabelText('名前');
await user.type(input, '初期値');
// 入力値をクリアしてから新しい値を入力
await user.clear(input);
await user.type(input, '新しい値');user.upload() - ファイルアップロード
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();
});
});
});user.tab() - フォーカス移動
バリデーションをトリガーするためにフォーカスを外す場合に便利です:
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();
});user.paste() - ペースト操作
大量のテキストを入力する場合、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('アップロード完了');
});
});waitFor vs findBy の使い分け
- findBy*: 要素の出現を待つ場合
- waitFor: 状態変化や複数の条件を待つ場合
// findBy: 単一要素の出現を待つ
const element = await screen.findByRole('button', { name: '送信' });
// waitFor: 複雑な条件を待つ
await waitFor(() => {
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(screen.getByText('完了')).toBeInTheDocument();
});userEventのベストプラクティスまとめ
- render前にuserEvent.setup()を呼ぶ - イベントハンドラが正しく設定された状態でテスト開始
- すべてのuserEvent呼び出しをawaitする - 非同期処理を確実に待機
- fireEventではなくuserEventを使う - より現実的なユーザー操作をシミュレート
- user.clear()で入力値をリセット - type()の前に既存値をクリア
- 大量テキストはuser.paste()を使う - type()より高速
カスタムtest-utilsで効率化
Providerラッピングの共通化
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 role" エラー
// エラー: Unable to find an element with the role "button" and name "送信"
// デバッグ: 利用可能なroleを確認
screen.debug(); // DOM全体を出力
// Testing Playgroundで確認
screen.logTestingPlaygroundURL(); // ブラウザで開けるURLを出力よくある原因:
nameオプションの大文字小文字が一致していない- 要素がまだレンダリングされていない(
findBy*を使うべき) - aria-labelやボタンテキストが期待と異なる
非同期テストのタイムアウト
// タイムアウトを延長する
await waitFor(
() => {
expect(screen.getByText('完了')).toBeInTheDocument();
},
{ timeout: 5000 } // デフォルト1000msを延長
);モックが効かない・リセットされない
// 各テスト前にモックをリセット
beforeEach(() => {
vi.clearAllMocks(); // 呼び出し履歴をクリア
// vi.resetAllMocks(); // 実装もリセットする場合
});
// 特定のモックのみリセット
afterEach(() => {
mocks.userService.getCurrentUser.mockReset();
});まとめ
Reactテストで悩まないための3つのキーポイント:
- vi.hoistedでモック定義を安全に - ESMモジュールでも確実に動作するモッキングパターン
- getByRoleを第一選択に - アクセシビリティとテストの堅牢性を同時に確保
- userEventで実際の操作を再現 - ユーザー視点のテストでバグを見逃さない
これらのベストプラクティスを適用することで、保守しやすく、信頼性の高いテストスイートを構築できます。
次のステップとして、あなたのプロジェクトでtest-utils.tsxを作成し、カスタムrender関数を導入してみてください。テストの記述量が減り、可読性が向上することを実感できるはずです。
