React アプリケーションテストガイド

ReactテストJestReact Testing LibraryCypressE2E テスト単体テスト統合テスト

概要

React アプリケーションのテストは、品質の高いソフトウェアを開発する上で不可欠な要素です。このガイドでは、Jest、React Testing Library、Cypress を使用した包括的なテスト戦略について解説します。単体テストから統合テスト、エンドツーエンド(E2E)テストまで、各レベルのテストを効果的に実装する方法を学びます。

詳細

テストピラミッドとテスト戦略

React アプリケーションのテストは、以下の3つのレベルで構成されます:

  1. 単体テスト(Unit Tests)

    • 個々のコンポーネントや関数の動作を検証
    • Jest と React Testing Library を使用
    • 実行速度が速く、頻繁に実行可能
  2. 統合テスト(Integration Tests)

    • 複数のコンポーネントの連携を検証
    • React Testing Library を使用
    • API モックやステート管理との統合をテスト
  3. 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 リクエストをモック・監視

モッキング戦略

効果的なテストのためのモッキング手法:

  1. API モック: MSW(Mock Service Worker)を使用したネットワークレベルのモック
  2. モジュールモック: Jest の jest.mock() を使用
  3. コンポーネントモック: 子コンポーネントの簡略化
  4. タイマーモック: jest.useFakeTimers() で時間依存のテスト

テストカバレッジ

適切なカバレッジ目標:

  • 単体テスト: 80-90% のカバレッジ
  • 統合テスト: 主要なユーザーフローをカバー
  • E2E テスト: クリティカルパスのみをテスト

パフォーマンステスト

React アプリケーションのパフォーマンステスト:

  1. React DevTools Profiler: コンポーネントのレンダリング分析
  2. Lighthouse: Web パフォーマンス指標の測定
  3. Bundle サイズ分析: webpack-bundle-analyzer の活用

ビジュアルリグレッションテスト

UI の予期しない変更を検出:

  • Chromatic: Storybook と統合したビジュアルテスト
  • Percy: CI/CD パイプラインでの自動ビジュアルテスト
  • スナップショットテスト: Jest のスナップショット機能

CI/CD 統合

継続的インテグレーションでのテスト自動化:

  1. GitHub Actions: テストワークフローの設定
  2. テスト並列実行: 実行時間の短縮
  3. 条件付きテスト: 変更されたファイルに基づくテスト実行
  4. テスト結果の可視化: レポートの自動生成

メリット・デメリット

メリット

  1. バグの早期発見: 開発サイクルの早い段階で問題を検出
  2. リファクタリングの安全性: テストがセーフティネットとして機能
  3. ドキュメント化: テストがコードの仕様書として機能
  4. 開発速度の向上: 自信を持って変更を加えられる
  5. 品質保証: 一貫した品質基準の維持

デメリット

  1. 初期投資: テスト環境の構築に時間が必要
  2. メンテナンスコスト: テストコードの保守が必要
  3. 学習曲線: 効果的なテストの書き方を習得する必要
  4. 実行時間: 大規模なテストスイートは時間がかかる
  5. 過度なモック: 現実と乖離したテストになるリスク

参考ページ

書き方の例

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();
});