React アプリケーションテストガイド
概要
React アプリケーションのテストは、品質の高いソフトウェアを開発する上で不可欠な要素です。このガイドでは、Jest、React Testing Library、Cypress を使用した包括的なテスト戦略について解説します。単体テストから統合テスト、エンドツーエンド(E2E)テストまで、各レベルのテストを効果的に実装する方法を学びます。
詳細
テストピラミッドとテスト戦略
React アプリケーションのテストは、以下の3つのレベルで構成されます:
-
単体テスト(Unit Tests)
- 個々のコンポーネントや関数の動作を検証
- Jest と React Testing Library を使用
- 実行速度が速く、頻繁に実行可能
-
統合テスト(Integration Tests)
- 複数のコンポーネントの連携を検証
- React Testing Library を使用
- API モックやステート管理との統合をテスト
-
E2E テスト(End-to-End Tests)
- 実際のブラウザでユーザーフローを検証
- Cypress を使用
- 本番環境に近い状態でのテスト
Jest による単体テスト
Jest は Facebook が開発した JavaScript テストフレームワークで、以下の特徴があります:
- ゼロ設定: すぐに使い始められる
- スナップショットテスト: UI の変更を検知
- モック機能: 外部依存関係を簡単にモック化
- カバレッジレポート: テストカバレッジを自動計測
React Testing Library のベストプラクティス
React Testing Library は、ユーザー視点でのテストを推奨するライブラリです:
- 実装詳細を避ける: コンポーネントの内部実装ではなく、ユーザーが見る UI をテスト
- アクセシビリティを重視:
getByRole
などのクエリを優先使用 - 非同期処理への対応:
findBy*
クエリやwaitFor
を活用 - 自動クリーンアップ: 各テスト後に自動的にコンポーネントをアンマウント
Cypress による E2E テスト
Cypress は実際のブラウザでアプリケーションをテストする強力なツールです:
- リアルブラウザテスト: Chrome、Firefox などで実行
- タイムトラベル: 各ステップをデバッグ可能
- 自動待機: 要素の表示を自動的に待機
- ネットワーク制御: API リクエストをモック・監視
モッキング戦略
効果的なテストのためのモッキング手法:
- API モック: MSW(Mock Service Worker)を使用したネットワークレベルのモック
- モジュールモック: Jest の
jest.mock()
を使用 - コンポーネントモック: 子コンポーネントの簡略化
- タイマーモック:
jest.useFakeTimers()
で時間依存のテスト
テストカバレッジ
適切なカバレッジ目標:
- 単体テスト: 80-90% のカバレッジ
- 統合テスト: 主要なユーザーフローをカバー
- E2E テスト: クリティカルパスのみをテスト
パフォーマンステスト
React アプリケーションのパフォーマンステスト:
- React DevTools Profiler: コンポーネントのレンダリング分析
- Lighthouse: Web パフォーマンス指標の測定
- Bundle サイズ分析: webpack-bundle-analyzer の活用
ビジュアルリグレッションテスト
UI の予期しない変更を検出:
- Chromatic: Storybook と統合したビジュアルテスト
- Percy: CI/CD パイプラインでの自動ビジュアルテスト
- スナップショットテスト: Jest のスナップショット機能
CI/CD 統合
継続的インテグレーションでのテスト自動化:
- GitHub Actions: テストワークフローの設定
- テスト並列実行: 実行時間の短縮
- 条件付きテスト: 変更されたファイルに基づくテスト実行
- テスト結果の可視化: レポートの自動生成
メリット・デメリット
メリット
- バグの早期発見: 開発サイクルの早い段階で問題を検出
- リファクタリングの安全性: テストがセーフティネットとして機能
- ドキュメント化: テストがコードの仕様書として機能
- 開発速度の向上: 自信を持って変更を加えられる
- 品質保証: 一貫した品質基準の維持
デメリット
- 初期投資: テスト環境の構築に時間が必要
- メンテナンスコスト: テストコードの保守が必要
- 学習曲線: 効果的なテストの書き方を習得する必要
- 実行時間: 大規模なテストスイートは時間がかかる
- 過度なモック: 現実と乖離したテストになるリスク
参考ページ
- Jest 公式ドキュメント
- React Testing Library 公式ドキュメント
- Cypress 公式ドキュメント
- Testing Library のベストプラクティス
- React 公式テストガイド
書き方の例
1. 基本的なコンポーネントテスト(React Testing Library)
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter コンポーネント', () => {
test('初期値が表示される', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('カウント: 5')).toBeInTheDocument();
});
test('ボタンクリックでカウントが増加する', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: '増加' });
await user.click(button);
expect(screen.getByText('カウント: 1')).toBeInTheDocument();
});
});
2. 非同期処理のテスト
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
import { server } from './mocks/server';
import { rest } from 'msw';
test('ユーザー情報を取得して表示する', async () => {
// MSW でAPIモック
server.use(
rest.get('/api/user/:id', (req, res, ctx) => {
return res(
ctx.json({
id: req.params.id,
name: '田中太郎',
email: '[email protected]'
})
);
})
);
render(<UserProfile userId="123" />);
// ローディング表示の確認
expect(screen.getByText('読み込み中...')).toBeInTheDocument();
// データ取得後の表示確認
await waitFor(() => {
expect(screen.getByText('田中太郎')).toBeInTheDocument();
});
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
3. カスタムフックのテスト
import { renderHook, act } from '@testing-library/react';
import useLocalStorage from './useLocalStorage';
test('useLocalStorage フックが正しく動作する', () => {
const { result } = renderHook(() =>
useLocalStorage('testKey', 'initial')
);
// 初期値の確認
expect(result.current[0]).toBe('initial');
// 値の更新
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
// localStorage への保存確認
expect(localStorage.getItem('testKey')).toBe('"updated"');
});
4. Cypress による E2E テスト
// cypress/e2e/login.cy.js
describe('ログインフロー', () => {
beforeEach(() => {
cy.visit('/login');
});
it('正常なログインができる', () => {
// フォーム入力
cy.get('[data-testid="email-input"]')
.type('[email protected]');
cy.get('[data-testid="password-input"]')
.type('password123');
// ログインボタンクリック
cy.get('[data-testid="login-button"]').click();
// リダイレクト確認
cy.url().should('include', '/dashboard');
// ウェルカムメッセージ確認
cy.contains('ようこそ、ユーザーさん').should('be.visible');
});
it('エラーメッセージが表示される', () => {
// 不正な認証情報を入力
cy.get('[data-testid="email-input"]')
.type('[email protected]');
cy.get('[data-testid="password-input"]')
.type('wrongpassword');
cy.get('[data-testid="login-button"]').click();
// エラーメッセージ確認
cy.get('[role="alert"]')
.should('contain', 'メールアドレスまたはパスワードが間違っています');
});
});
5. スナップショットテスト
import { render } from '@testing-library/react';
import Button from './Button';
test('Button コンポーネントのスナップショット', () => {
const { asFragment } = render(
<Button variant="primary" size="large">
クリック
</Button>
);
expect(asFragment()).toMatchSnapshot();
});
6. テストユーティリティの作成
// test-utils.js
import { render } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { theme } from './theme';
import { store } from './store';
// カスタムレンダー関数
export function renderWithProviders(
ui,
{
initialState,
store = configureStore({ reducer, initialState }),
...renderOptions
} = {}
) {
function Wrapper({ children }) {
return (
<Provider store={store}>
<ThemeProvider theme={theme}>
<BrowserRouter>
{children}
</BrowserRouter>
</ThemeProvider>
</Provider>
);
}
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions })
};
}
// 使用例
test('認証済みユーザーのダッシュボード', () => {
const initialState = {
auth: {
isAuthenticated: true,
user: { id: 1, name: '田中' }
}
};
renderWithProviders(<Dashboard />, { initialState });
expect(screen.getByText('田中さん、こんにちは')).toBeInTheDocument();
});