第13章: テスト

テストは、アプリケーションが意図した通りに動作することを自動的に検証する仕組みです。 この章では、Vite プロジェクトで標準的に使用される VitestReact Testing Library を組み合わせて、 React コンポーネントのテストを書く方法を解説します。

テストの概要

フロントエンドのテストは主に以下の 3 つのレベルに分類されます。

テストレベル対象ツール例速度
単体テスト (Unit Test) 個々の関数やコンポーネント Vitest, React Testing Library 高速
結合テスト (Integration Test) 複数のコンポーネントの連携 Vitest, React Testing Library 中速
E2E テスト (End-to-End) ブラウザでのユーザー操作全体 Playwright, Cypress 低速

この章では、単体テストと結合テストにフォーカスします。 テストの数は「単体テスト > 結合テスト > E2E テスト」が理想的とされ、 これをテストピラミッドと呼びます。

使用するツール

ツール役割
Vitest テストランナー。Vite ベースで高速に動作し、Jest 互換の API を提供。
React Testing Library React コンポーネントのテスト用ユーティリティ。ユーザー目線でテストを記述できる。
jsdom Node.js 上でブラウザの DOM をシミュレーションする環境。
@testing-library/user-event ユーザーの操作(クリック、入力、キーボード操作)をシミュレーションする。

テスト環境のセットアップ

Vite で作成した React プロジェクトにテスト環境を追加する手順です。

1. パッケージのインストール

ターミナル
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

2. vite.config.js にテスト設定を追加

vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',     // ブラウザ DOM をシミュレーション
    globals: true,              // describe, it, expect をグローバルに
    setupFiles: './src/test/setup.js',  // テスト共通設定
  },
})

3. テストのセットアップファイルを作成

src/test/setup.js
import '@testing-library/jest-dom';

@testing-library/jest-dom をインポートすることで、 toBeInTheDocument()toHaveTextContent() などの DOM 専用マッチャーが使えるようになります。

4. package.json にテストスクリプトを追加

package.json(抜粋)
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:run": "vitest run"
  }
}

npm test はウォッチモード(ファイル変更時に自動再実行)で起動します。 npm run test:run は 1 回だけ実行して終了します(CI/CD 向け)。

最初のテスト

まずは簡単な関数のテストから始めましょう。テストファイルは .test.js または .test.jsx の拡張子を付けます。

src/utils/formatPrice.js
export function formatPrice(price) {
  return `${price.toLocaleString()} 円`;
}
src/utils/formatPrice.test.js
import { formatPrice } from './formatPrice';

describe('formatPrice', () => {
  it('数値を円表記にフォーマットする', () => {
    expect(formatPrice(1000)).toBe('1,000 円');
  });

  it('0 を正しくフォーマットする', () => {
    expect(formatPrice(0)).toBe('0 円');
  });

  it('大きな数値にカンマを挿入する', () => {
    expect(formatPrice(1234567)).toBe('1,234,567 円');
  });
});
テストの実行
npm test

テスト構文の基本的な要素を確認しましょう。

構文説明
describe('名前', fn)テストグループ(テストスイート)を定義する
it('名前', fn)個々のテストケースを定義する(test() と同義)
expect(値)アサーションの対象値を指定する
.toBe(期待値)厳密等価(===)で比較するマッチャー
.toEqual(期待値)オブジェクト・配列の深い比較を行うマッチャー

コンポーネントのテスト

React Testing Library を使って、コンポーネントが正しくレンダリングされるかをテストします。 render 関数でコンポーネントを描画し、screen オブジェクトで要素を取得します。

src/components/Greeting.jsx
function Greeting({ name }) {
  return <h1>こんにちは、{name} さん!</h1>;
}

export default Greeting;
src/components/Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

describe('Greeting', () => {
  it('名前を含む挨拶を表示する', () => {
    render(<Greeting name="太郎" />);

    // テキストで要素を検索
    const heading = screen.getByText('こんにちは、太郎 さん!');
    expect(heading).toBeInTheDocument();
  });

  it('h1 要素としてレンダリングされる', () => {
    render(<Greeting name="花子" />);

    const heading = screen.getByRole('heading', { level: 1 });
    expect(heading).toHaveTextContent('こんにちは、花子 さん!');
  });
});

要素の取得方法

React Testing Library は「ユーザーが見える情報」で要素を取得することを推奨しています。

メソッド用途
getByRole ARIA ロールで検索(推奨) getByRole('button', { name: '送信' })
getByText テキスト内容で検索 getByText('こんにちは')
getByLabelText ラベルで検索(フォーム向け) getByLabelText('メールアドレス')
getByPlaceholderText プレースホルダーで検索 getByPlaceholderText('検索...')
getByTestId data-testid 属性で検索(最後の手段) getByTestId('submit-button')

要素の取得は getByRole を最優先で使いましょう。 ARIA ロールベースの取得は、アクセシビリティの改善にもつながります。 例えば getByRole('button') で取得できない場合、 そのボタンにはアクセシビリティ上の問題がある可能性があります。

ユーザー操作のテスト

@testing-library/user-event を使って、ボタンのクリックやテキスト入力など、 ユーザーの操作をシミュレーションします。

src/components/Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(0)}>リセット</button>
    </div>
  );
}

export default Counter;
src/components/Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  it('初期値は 0', () => {
    render(<Counter />);
    expect(screen.getByText('カウント: 0')).toBeInTheDocument();
  });

  it('+1 ボタンでカウントが増加する', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: '+1' }));
    expect(screen.getByText('カウント: 1')).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: '+1' }));
    expect(screen.getByText('カウント: 2')).toBeInTheDocument();
  });

  it('リセットボタンでカウントが 0 になる', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    // 3 回クリック
    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: '+1' }));
    expect(screen.getByText('カウント: 3')).toBeInTheDocument();

    // リセット
    await user.click(screen.getByRole('button', { name: 'リセット' }));
    expect(screen.getByText('カウント: 0')).toBeInTheDocument();
  });
});

userEvent.setup() は user-event v14 以降の推奨APIです。 user.click()user.type() などの操作は非同期なので await を忘れないでください。

非同期処理のテスト

API からデータを取得するコンポーネントのテストでは、 データが表示されるまで待機する必要があります。findBy* メソッドや waitFor を使って非同期処理の完了を待ちます。

メソッドプレフィックス見つからない場合非同期用途
getBy*即座にエラー同期要素が確実に存在する場合
queryBy*null を返す同期要素が存在しないことを確認する場合
findBy*タイムアウトでエラー非同期非同期で表示される要素を待つ場合
非同期テストの例
import { render, screen } from '@testing-library/react';
import UserList from './UserList';

it('API から取得したユーザーを表示する', async () => {
  render(<UserList />);

  // ローディング表示を確認
  expect(screen.getByText('読み込み中...')).toBeInTheDocument();

  // データが表示されるまで待機(findBy は非同期)
  const user = await screen.findByText('田中太郎');
  expect(user).toBeInTheDocument();

  // ローディングが消えたことを確認
  expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});

API のモック

テストでは実際の API サーバーにリクエストを送らず、 モック(偽のレスポンス)を使って API の振る舞いをシミュレーションします。 Vitest の vi.fn()vi.spyOn() でモックを作成できます。

fetch のモック
import { render, screen } from '@testing-library/react';
import UserList from './UserList';

// fetch をモック化
globalThis.fetch = vi.fn();

describe('UserList', () => {
  it('ユーザー一覧を表示する', async () => {
    // モックのレスポンスを設定
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => [
        { id: 1, name: '田中太郎' },
        { id: 2, name: '鈴木花子' },
      ],
    });

    render(<UserList />);

    // データの表示を待機
    expect(await screen.findByText('田中太郎')).toBeInTheDocument();
    expect(screen.getByText('鈴木花子')).toBeInTheDocument();

    // fetch が正しい URL で呼ばれたか確認
    expect(fetch).toHaveBeenCalledWith('/api/users');
  });

  it('エラー時にエラーメッセージを表示する', async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 500,
    });

    render(<UserList />);

    expect(await screen.findByText(/エラー/)).toBeInTheDocument();
  });
});

よく使うテストパターン

テキスト入力のテスト

テキスト入力のテスト
it('テキストを入力できる', async () => {
  const user = userEvent.setup();
  render(<SearchBox />);

  const input = screen.getByPlaceholderText('検索...');
  await user.type(input, 'React');

  expect(input).toHaveValue('React');
});

条件付きレンダリングのテスト

条件付きレンダリングのテスト
it('ログイン済みならダッシュボードを表示する', () => {
  render(<Dashboard isLoggedIn={true} />);
  expect(screen.getByText('ダッシュボード')).toBeInTheDocument();
});

it('未ログインならログイン促進メッセージを表示する', () => {
  render(<Dashboard isLoggedIn={false} />);
  expect(screen.getByText('ログインしてください')).toBeInTheDocument();
  expect(screen.queryByText('ダッシュボード')).not.toBeInTheDocument();
});

フォーム送信のテスト

フォーム送信のテスト
it('フォームを送信できる', async () => {
  const handleSubmit = vi.fn();
  const user = userEvent.setup();

  render(<ContactForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('名前'), '田中太郎');
  await user.type(screen.getByLabelText('メール'), '[email protected]');
  await user.click(screen.getByRole('button', { name: '送信' }));

  expect(handleSubmit).toHaveBeenCalledWith({
    name: '田中太郎',
    email: '[email protected]',
  });
});

テストのベストプラクティス

項目推奨事項
ユーザー目線でテストする 実装の詳細(State、内部メソッド)ではなく、ユーザーが見る結果(表示テキスト、操作の反映)をテストする
1 テスト 1 検証 各テストケースは 1 つの振る舞いのみを検証する。テストが失敗したとき原因が特定しやすい
テストを独立させる テストの実行順序に依存しない。各テストは他のテストの影響を受けないようにする
getByRole を優先 アクセシビリティロールで要素を取得する。getByTestId は最後の手段
テストファイルの配置 テスト対象ファイルと同じディレクトリに置く(例: Button.jsxButton.test.jsx
テスト名を分かりやすく 「何をすると」「どうなるか」が分かるテスト名をつける(例: 「送信ボタンをクリックすると確認メッセージが表示される」)

テストを書く習慣をつけましょう。 最初は主要なコンポーネントや、バグが発生しやすい箇所からテストを書き始めるのがおすすめです。 すべてのコードを 100% カバーする必要はありません。 重要な機能とエッジケースを優先的にテストしましょう。