T-CREATOR

React コンポーネントを Jest でテストする手順

React コンポーネントを Jest でテストする手順

現代の React 開発において、コンポーネントの品質を保証するためのテストは必要不可欠な要素です。UI コンポーネントのテストは、アプリケーションの信頼性を高め、リファクタリングやアップデートの際にも安心して作業を進められる基盤となります。

この記事では、Jest と React Testing Library を組み合わせて、実際の React コンポーネントを効果的にテストする方法を実践的に解説していきます。基本的なレンダリングテストから、ユーザーインタラクション、状態管理、非同期処理まで、現場で即座に活用できる知識を体系的に学んでいきましょう。

React コンポーネントテストの重要性

なぜコンポーネントテストが必要なのか

React アプリケーションにおいて、コンポーネントテストが重要視される理由は多岐にわたります。

#目的具体的な効果
1バグの早期発見開発段階でのデグレーション防止
2リファクタリングの安全性既存機能を壊さない変更の保証
3ドキュメント代わりコンポーネントの期待動作の明文化
4開発効率の向上手動テストの時間短縮
5チーム開発の品質統一統一されたテスト基準による品質保証

UI の複雑性への対応 現代の Web アプリケーションでは、ユーザーインタラクションが複雑化しており、状態変更やイベント処理の組み合わせが増えています。

保守性の向上 適切にテストされたコンポーネントは、将来的な変更や機能追加の際にも安心して作業を進められます。

React コンポーネントテストの特徴

React コンポーネントのテストには、従来の JavaScript 関数テストとは異なる特徴があります。

仮想 DOM のテスト React コンポーネントは仮想 DOM を生成するため、実際の DOM 操作とは異なるアプローチが必要です。

プロパティとステートの管理 コンポーネントの動作は、外部から渡されるプロパティと内部状態によって決まるため、これらの組み合わせをテストする必要があります。

ライフサイクルとエフェクトの考慮 useEffect や useLayoutEffect などの React Hooks の動作も含めてテストを設計することが重要です。

React Testing Library との組み合わせ

React Testing Library とは

React Testing Library は、React コンポーネントを「ユーザーがどのように使用するか」という観点からテストするためのライブラリです。

typescript// React Testing Library の基本的なインポート
import {
  render,
  screen,
  fireEvent,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

主要な理念 React Testing Library は「実装の詳細ではなく、動作をテストする」という哲学に基づいて設計されています。これにより、リファクタリングに強いテストを書くことができます。

Jest と React Testing Library の連携

Jest 単体では React コンポーネントのテストが困難ですが、React Testing Library と組み合わせることで強力なテスト環境が構築できます。

typescript// package.json の依存関係例
{
  "devDependencies": {
    "@testing-library/react": "^13.4.0",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/user-event": "^14.4.3",
    "jest": "^29.3.1",
    "jest-environment-jsdom": "^29.3.1"
  }
}

Jest の設定例

typescript// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
  collectCoverageFrom: [
    'src/components/**/*.{ts,tsx}',
    '!src/components/**/*.stories.{ts,tsx}',
  ],
};

setupTests.ts の設定

typescript// src/setupTests.ts
import '@testing-library/jest-dom';

// カスタムマッチャーの追加
expect.extend({
  toBeInTheDocument: () => ({
    pass: true,
    message: () => '',
  }),
});

基本的なテストセットアップ

React Testing Library を使用したテストの基本的な構造を理解しましょう。

typescriptimport React from 'react';
import { render, screen } from '@testing-library/react';
import { MyComponent } from './MyComponent';

describe('MyComponent のテスト', () => {
  test('コンポーネントが正常にレンダリングされる', () => {
    // Arrange(準備)
    const props = { title: 'テストタイトル' };

    // Act(実行)
    render(<MyComponent {...props} />);

    // Assert(検証)
    expect(
      screen.getByText('テストタイトル')
    ).toBeInTheDocument();
  });
});

AAA パターンの活用

  • Arrange(準備): テストに必要なデータやモックを準備
  • Act(実行): テスト対象のアクションを実行
  • Assert(検証): 期待される結果を検証

この基本的な構造を理解することで、より複雑なテストケースにも対応できるようになります。

テストファイルの命名規則

React コンポーネントのテストファイルは、一貫した命名規則に従うことが重要です。

#ファイル名パターン使用場面
1Component.test.tsx基本的なテストファイル
2Component.spec.tsx詳細仕様のテスト
3Component.integration.test.tsx統合テスト
4__tests__​/​Component.tsxテスト専用ディレクトリ

推奨ディレクトリ構造

csssrc/
  components/
    Button/
      Button.tsx
      Button.test.tsx
      Button.stories.tsx
    Card/
      Card.tsx
      Card.test.tsx
      index.ts

このような構造により、コンポーネントとそのテストファイルの関係が明確になり、保守性が向上します。

基本的なコンポーネントのレンダリングテスト

シンプルなプレゼンテーショナルコンポーネントのテスト

まずは、最も基本的なプレゼンテーショナルコンポーネントのテストから始めましょう。

typescript// src/components/Greeting/Greeting.tsx
import React from 'react';

interface GreetingProps {
  name: string;
  age?: number;
  isVip?: boolean;
}

export const Greeting: React.FC<GreetingProps> = ({
  name,
  age,
  isVip = false,
}) => {
  return (
    <div className='greeting'>
      <h1>こんにちは、{name}さん!</h1>
      {age && <p>年齢: {age}歳</p>}
      {isVip && <span className='vip-badge'>VIP</span>}
    </div>
  );
};

このコンポーネントの基本的なレンダリングテストを書いてみましょう。

typescript// src/components/Greeting/Greeting.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

describe('Greeting コンポーネント', () => {
  test('名前が正しく表示される', () => {
    render(<Greeting name='田中太郎' />);

    expect(
      screen.getByText('こんにちは、田中太郎さん!')
    ).toBeInTheDocument();
  });

  test('年齢が提供された場合、年齢が表示される', () => {
    render(<Greeting name='佐藤花子' age={25} />);

    expect(
      screen.getByText('年齢: 25歳')
    ).toBeInTheDocument();
  });

  test('年齢が提供されない場合、年齢は表示されない', () => {
    render(<Greeting name='山田次郎' />);

    expect(
      screen.queryByText(/年齢:/)
    ).not.toBeInTheDocument();
  });

  test('VIP フラグが true の場合、VIP バッジが表示される', () => {
    render(<Greeting name='鈴木一郎' isVip={true} />);

    expect(screen.getByText('VIP')).toBeInTheDocument();
    expect(screen.getByText('VIP')).toHaveClass(
      'vip-badge'
    );
  });

  test('VIP フラグが false または未設定の場合、VIP バッジは表示されない', () => {
    render(<Greeting name='高橋美咲' />);

    expect(
      screen.queryByText('VIP')
    ).not.toBeInTheDocument();
  });
});

要素の取得方法と優先順位

React Testing Library では、要素を取得するための複数のメソッドが用意されています。適切なメソッドを選択することが重要です。

推奨される取得方法の優先順位

#メソッド使用場面
1getByRoleセマンティックな役割での要素取得getByRole('button', { name: '送信' })
2getByLabelTextフォーム要素のラベルテキストでの取得getByLabelText('メールアドレス')
3getByPlaceholderTextプレースホルダーテキストでの取得getByPlaceholderText('名前を入力')
4getByText表示テキストでの要素取得getByText('保存')
5getByDisplayValueフォーム要素の現在値での取得getByDisplayValue('初期値')
6getByAltText画像の alt 属性での取得getByAltText('プロフィール画像')
7getByTitletitle 属性での取得getByTitle('ヘルプ情報')
8getByTestIddata-testid 属性での取得(最後の手段)getByTestId('user-profile')
typescript// 良い例: 役割ベースでの要素取得
describe('Button コンポーネントの取得テスト', () => {
  test('ボタンが正しく表示される', () => {
    render(<button>クリック</button>);

    // 役割で取得(推奨)
    expect(
      screen.getByRole('button', { name: 'クリック' })
    ).toBeInTheDocument();
  });

  test('リンクが正しく表示される', () => {
    render(<a href='/home'>ホーム</a>);

    // 役割で取得(推奨)
    expect(
      screen.getByRole('link', { name: 'ホーム' })
    ).toBeInTheDocument();
  });
});

// テストID は最後の手段として使用
describe('複雑な要素の取得', () => {
  test('特定のコンテナ要素が存在する', () => {
    render(
      <div data-testid='user-profile-container'>
        <span>ユーザープロフィール</span>
      </div>
    );

    expect(
      screen.getByTestId('user-profile-container')
    ).toBeInTheDocument();
  });
});

複数要素の取得とリストのテスト

複数の要素が存在する場合のテスト方法を学びましょう。

typescript// src/components/UserList/UserList.tsx
import React from 'react';

interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

interface UserListProps {
  users: User[];
  showEmail?: boolean;
}

export const UserList: React.FC<UserListProps> = ({
  users,
  showEmail = false,
}) => {
  if (users.length === 0) {
    return <p>ユーザーが見つかりません</p>;
  }

  return (
    <ul role='list'>
      {users.map((user) => (
        <li key={user.id} role='listitem'>
          <div
            className={`user-item ${
              user.isActive ? 'active' : 'inactive'
            }`}
          >
            <h3>{user.name}</h3>
            {showEmail && <p>{user.email}</p>}
            <span className='status'>
              {user.isActive
                ? 'アクティブ'
                : '非アクティブ'}
            </span>
          </div>
        </li>
      ))}
    </ul>
  );
};
typescript// src/components/UserList/UserList.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { UserList } from './UserList';

const mockUsers = [
  {
    id: 1,
    name: '田中太郎',
    email: 'tanaka@example.com',
    isActive: true,
  },
  {
    id: 2,
    name: '佐藤花子',
    email: 'sato@example.com',
    isActive: false,
  },
  {
    id: 3,
    name: '山田次郎',
    email: 'yamada@example.com',
    isActive: true,
  },
];

describe('UserList コンポーネント', () => {
  test('ユーザーリストが正しく表示される', () => {
    render(<UserList users={mockUsers} />);

    // リスト全体の確認
    expect(screen.getByRole('list')).toBeInTheDocument();

    // 各ユーザーの確認
    expect(
      screen.getByText('田中太郎')
    ).toBeInTheDocument();
    expect(
      screen.getByText('佐藤花子')
    ).toBeInTheDocument();
    expect(
      screen.getByText('山田次郎')
    ).toBeInTheDocument();

    // リストアイテムの数を確認
    const listItems = screen.getAllByRole('listitem');
    expect(listItems).toHaveLength(3);
  });

  test('空のユーザーリストの場合、適切なメッセージが表示される', () => {
    render(<UserList users={[]} />);

    expect(
      screen.getByText('ユーザーが見つかりません')
    ).toBeInTheDocument();
    expect(
      screen.queryByRole('list')
    ).not.toBeInTheDocument();
  });

  test('showEmail が true の場合、メールアドレスが表示される', () => {
    render(<UserList users={mockUsers} showEmail={true} />);

    expect(
      screen.getByText('tanaka@example.com')
    ).toBeInTheDocument();
    expect(
      screen.getByText('sato@example.com')
    ).toBeInTheDocument();
    expect(
      screen.getByText('yamada@example.com')
    ).toBeInTheDocument();
  });

  test('showEmail が false の場合、メールアドレスは表示されない', () => {
    render(
      <UserList users={mockUsers} showEmail={false} />
    );

    expect(
      screen.queryByText('tanaka@example.com')
    ).not.toBeInTheDocument();
    expect(
      screen.queryByText('sato@example.com')
    ).not.toBeInTheDocument();
    expect(
      screen.queryByText('yamada@example.com')
    ).not.toBeInTheDocument();
  });

  test('ユーザーのアクティブ状態が正しく表示される', () => {
    render(<UserList users={mockUsers} />);

    // アクティブなユーザーのステータス確認
    const activeStatuses =
      screen.getAllByText('アクティブ');
    expect(activeStatuses).toHaveLength(2);

    // 非アクティブなユーザーのステータス確認
    const inactiveStatuses =
      screen.getAllByText('非アクティブ');
    expect(inactiveStatuses).toHaveLength(1);
  });

  test('CSS クラスが正しく適用される', () => {
    render(<UserList users={[mockUsers[0]]} />);

    const userItem = screen
      .getByText('田中太郎')
      .closest('.user-item');
    expect(userItem).toHaveClass('user-item', 'active');
  });
});

スナップショットテストの活用

スナップショットテストは、コンポーネントのレンダリング結果を記録し、将来の変更を検出するのに役立ちます。

typescriptdescribe('UserList スナップショットテスト', () => {
  test('基本的なユーザーリストのスナップショット', () => {
    const { container } = render(
      <UserList users={mockUsers} />
    );
    expect(container.firstChild).toMatchSnapshot();
  });

  test('メール表示ありのスナップショット', () => {
    const { container } = render(
      <UserList users={mockUsers} showEmail={true} />
    );
    expect(container.firstChild).toMatchSnapshot();
  });

  test('空リストのスナップショット', () => {
    const { container } = render(<UserList users={[]} />);
    expect(container.firstChild).toMatchSnapshot();
  });
});

スナップショットテストを使用する際は、以下の点に注意しましょう。

スナップショットテストのベストプラクティス

  • 動的な値(現在時刻、ランダム値)は避ける
  • 意図的な変更時はスナップショットを更新する
  • 大きすぎるスナップショットは分割を検討する

これらの基本的なレンダリングテストをマスターすることで、React コンポーネントの品質を効果的に保証できるようになります。

ユーザーインタラクション(クリック、入力)のテスト

クリックイベントのテスト

ユーザーインタラクションのテストは、React アプリケーションの動作を確認する上で非常に重要です。まずは基本的なクリックイベントから始めましょう。

typescript// src/components/Counter/Counter.tsx
import React, { useState } from 'react';

interface CounterProps {
  initialValue?: number;
  step?: number;
  onCountChange?: (count: number) => void;
}

export const Counter: React.FC<CounterProps> = ({
  initialValue = 0,
  step = 1,
  onCountChange,
}) => {
  const [count, setCount] = useState(initialValue);

  const handleIncrement = () => {
    const newCount = count + step;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  const handleDecrement = () => {
    const newCount = count - step;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  const handleReset = () => {
    setCount(initialValue);
    onCountChange?.(initialValue);
  };

  return (
    <div className='counter'>
      <h2>カウンター</h2>
      <div className='counter-display'>
        <span data-testid='count-value'>{count}</span>
      </div>
      <div className='counter-controls'>
        <button onClick={handleDecrement}>-</button>
        <button onClick={handleIncrement}>+</button>
        <button onClick={handleReset}>リセット</button>
      </div>
    </div>
  );
};

このカウンターコンポーネントのクリックイベントをテストしてみましょう。

typescript// src/components/Counter/Counter.test.tsx
import React from 'react';
import {
  render,
  screen,
  fireEvent,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter コンポーネントのクリックテスト', () => {
  test('初期値が正しく表示される', () => {
    render(<Counter initialValue={5} />);

    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('5');
  });

  test('+ ボタンをクリックすると値が増加する', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} step={1} />);

    const incrementButton = screen.getByRole('button', {
      name: '+',
    });

    await user.click(incrementButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('1');

    await user.click(incrementButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('2');
  });

  test('- ボタンをクリックすると値が減少する', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={5} step={1} />);

    const decrementButton = screen.getByRole('button', {
      name: '-',
    });

    await user.click(decrementButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('4');

    await user.click(decrementButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('3');
  });

  test('リセットボタンをクリックすると初期値に戻る', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={10} />);

    const incrementButton = screen.getByRole('button', {
      name: '+',
    });
    const resetButton = screen.getByRole('button', {
      name: 'リセット',
    });

    // カウンターを変更
    await user.click(incrementButton);
    await user.click(incrementButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('12');

    // リセット
    await user.click(resetButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('10');
  });

  test('ステップ値が正しく適用される', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} step={5} />);

    const incrementButton = screen.getByRole('button', {
      name: '+',
    });

    await user.click(incrementButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('5');

    await user.click(incrementButton);
    expect(
      screen.getByTestId('count-value')
    ).toHaveTextContent('10');
  });

  test('コールバック関数が正しく呼ばれる', async () => {
    const user = userEvent.setup();
    const mockCallback = jest.fn();
    render(
      <Counter
        initialValue={0}
        onCountChange={mockCallback}
      />
    );

    const incrementButton = screen.getByRole('button', {
      name: '+',
    });

    await user.click(incrementButton);

    expect(mockCallback).toHaveBeenCalledWith(1);
    expect(mockCallback).toHaveBeenCalledTimes(1);
  });
});

フォーム入力のテスト

フォーム要素のテストは、ユーザーからの入力を扱うアプリケーションでは必須です。

typescript// src/components/ContactForm/ContactForm.tsx
import React, { useState } from 'react';

interface FormData {
  name: string;
  email: string;
  message: string;
}

interface ContactFormProps {
  onSubmit: (data: FormData) => void;
  isLoading?: boolean;
}

export const ContactForm: React.FC<ContactFormProps> = ({
  onSubmit,
  isLoading = false,
}) => {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    message: '',
  });

  const [errors, setErrors] = useState<Partial<FormData>>(
    {}
  );

  const validateForm = (): boolean => {
    const newErrors: Partial<FormData> = {};

    if (!formData.name.trim()) {
      newErrors.name = '名前は必須です';
    }

    if (!formData.email.trim()) {
      newErrors.email = 'メールアドレスは必須です';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email =
        '有効なメールアドレスを入力してください';
    }

    if (!formData.message.trim()) {
      newErrors.message = 'メッセージは必須です';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    if (validateForm()) {
      onSubmit(formData);
    }
  };

  const handleInputChange = (
    e: React.ChangeEvent<
      HTMLInputElement | HTMLTextAreaElement
    >
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({
      ...prev,
      [name]: value,
    }));

    // エラーをクリア
    if (errors[name as keyof FormData]) {
      setErrors((prev) => ({
        ...prev,
        [name]: undefined,
      }));
    }
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div className='form-group'>
        <label htmlFor='name'>名前</label>
        <input
          id='name'
          name='name'
          type='text'
          value={formData.name}
          onChange={handleInputChange}
          aria-invalid={!!errors.name}
          aria-describedby={
            errors.name ? 'name-error' : undefined
          }
        />
        {errors.name && (
          <span id='name-error' className='error-message'>
            {errors.name}
          </span>
        )}
      </div>

      <div className='form-group'>
        <label htmlFor='email'>メールアドレス</label>
        <input
          id='email'
          name='email'
          type='email'
          value={formData.email}
          onChange={handleInputChange}
          aria-invalid={!!errors.email}
          aria-describedby={
            errors.email ? 'email-error' : undefined
          }
        />
        {errors.email && (
          <span id='email-error' className='error-message'>
            {errors.email}
          </span>
        )}
      </div>

      <div className='form-group'>
        <label htmlFor='message'>メッセージ</label>
        <textarea
          id='message'
          name='message'
          rows={4}
          value={formData.message}
          onChange={handleInputChange}
          aria-invalid={!!errors.message}
          aria-describedby={
            errors.message ? 'message-error' : undefined
          }
        />
        {errors.message && (
          <span
            id='message-error'
            className='error-message'
          >
            {errors.message}
          </span>
        )}
      </div>

      <button type='submit' disabled={isLoading}>
        {isLoading ? '送信中...' : '送信'}
      </button>
    </form>
  );
};
typescript// src/components/ContactForm/ContactForm.test.tsx
import React from 'react';
import {
  render,
  screen,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';

describe('ContactForm コンポーネント', () => {
  const mockOnSubmit = jest.fn();

  beforeEach(() => {
    mockOnSubmit.mockClear();
  });

  test('フォームが正しくレンダリングされる', () => {
    render(<ContactForm onSubmit={mockOnSubmit} />);

    expect(
      screen.getByLabelText('名前')
    ).toBeInTheDocument();
    expect(
      screen.getByLabelText('メールアドレス')
    ).toBeInTheDocument();
    expect(
      screen.getByLabelText('メッセージ')
    ).toBeInTheDocument();
    expect(
      screen.getByRole('button', { name: '送信' })
    ).toBeInTheDocument();
  });

  test('入力フィールドに値を入力できる', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);

    const nameInput = screen.getByLabelText('名前');
    const emailInput =
      screen.getByLabelText('メールアドレス');
    const messageInput =
      screen.getByLabelText('メッセージ');

    await user.type(nameInput, '田中太郎');
    await user.type(emailInput, 'tanaka@example.com');
    await user.type(messageInput, 'テストメッセージです');

    expect(nameInput).toHaveValue('田中太郎');
    expect(emailInput).toHaveValue('tanaka@example.com');
    expect(messageInput).toHaveValue(
      'テストメッセージです'
    );
  });

  test('有効なデータでフォームを送信できる', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);

    // フォームに入力
    await user.type(
      screen.getByLabelText('名前'),
      '田中太郎'
    );
    await user.type(
      screen.getByLabelText('メールアドレス'),
      'tanaka@example.com'
    );
    await user.type(
      screen.getByLabelText('メッセージ'),
      'テストメッセージです'
    );

    // フォームを送信
    await user.click(
      screen.getByRole('button', { name: '送信' })
    );

    expect(mockOnSubmit).toHaveBeenCalledWith({
      name: '田中太郎',
      email: 'tanaka@example.com',
      message: 'テストメッセージです',
    });
  });

  test('必須フィールドが空の場合、エラーメッセージが表示される', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);

    // 空のフォームを送信
    await user.click(
      screen.getByRole('button', { name: '送信' })
    );

    await waitFor(() => {
      expect(
        screen.getByText('名前は必須です')
      ).toBeInTheDocument();
      expect(
        screen.getByText('メールアドレスは必須です')
      ).toBeInTheDocument();
      expect(
        screen.getByText('メッセージは必須です')
      ).toBeInTheDocument();
    });

    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  test('無効なメールアドレスの場合、エラーメッセージが表示される', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);

    await user.type(
      screen.getByLabelText('名前'),
      '田中太郎'
    );
    await user.type(
      screen.getByLabelText('メールアドレス'),
      '無効なメール'
    );
    await user.type(
      screen.getByLabelText('メッセージ'),
      'テストメッセージ'
    );

    await user.click(
      screen.getByRole('button', { name: '送信' })
    );

    await waitFor(() => {
      expect(
        screen.getByText(
          '有効なメールアドレスを入力してください'
        )
      ).toBeInTheDocument();
    });

    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  test('入力時にエラーメッセージがクリアされる', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);

    // エラーを発生させる
    await user.click(
      screen.getByRole('button', { name: '送信' })
    );

    await waitFor(() => {
      expect(
        screen.getByText('名前は必須です')
      ).toBeInTheDocument();
    });

    // 名前フィールドに入力してエラーをクリア
    await user.type(
      screen.getByLabelText('名前'),
      '田中太郎'
    );

    expect(
      screen.queryByText('名前は必須です')
    ).not.toBeInTheDocument();
  });

  test('ローディング状態でボタンが無効化される', () => {
    render(
      <ContactForm
        onSubmit={mockOnSubmit}
        isLoading={true}
      />
    );

    const submitButton = screen.getByRole('button', {
      name: '送信中...',
    });
    expect(submitButton).toBeDisabled();
  });
});

キーボードイベントのテスト

キーボード操作のテストも重要な要素です。

typescript// src/components/SearchBox/SearchBox.tsx
import React, { useState, useRef } from 'react';

interface SearchBoxProps {
  onSearch: (query: string) => void;
  onClear?: () => void;
  placeholder?: string;
  debounceMs?: number;
}

export const SearchBox: React.FC<SearchBoxProps> = ({
  onSearch,
  onClear,
  placeholder = '検索...',
}) => {
  const [query, setQuery] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (query.trim()) {
      onSearch(query.trim());
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      handleClear();
    }
  };

  const handleClear = () => {
    setQuery('');
    onClear?.();
    inputRef.current?.focus();
  };

  return (
    <form onSubmit={handleSubmit} className='search-box'>
      <div className='search-input-container'>
        <input
          ref={inputRef}
          type='text'
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          aria-label='検索'
        />
        {query && (
          <button
            type='button'
            onClick={handleClear}
            aria-label='クリア'
            className='clear-button'
          >
            ×
          </button>
        )}
        <button type='submit' aria-label='検索実行'>
          🔍
        </button>
      </div>
    </form>
  );
};
typescript// src/components/SearchBox/SearchBox.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBox } from './SearchBox';

describe('SearchBox キーボードイベントテスト', () => {
  const mockOnSearch = jest.fn();
  const mockOnClear = jest.fn();

  beforeEach(() => {
    mockOnSearch.mockClear();
    mockOnClear.mockClear();
  });

  test('Enter キーで検索が実行される', async () => {
    const user = userEvent.setup();
    render(<SearchBox onSearch={mockOnSearch} />);

    const input = screen.getByLabelText('検索');

    await user.type(input, 'テスト検索');
    await user.keyboard('{Enter}');

    expect(mockOnSearch).toHaveBeenCalledWith('テスト検索');
  });

  test('Escape キーでクリアされる', async () => {
    const user = userEvent.setup();
    render(
      <SearchBox
        onSearch={mockOnSearch}
        onClear={mockOnClear}
      />
    );

    const input = screen.getByLabelText('検索');

    await user.type(input, 'テスト');
    expect(input).toHaveValue('テスト');

    await user.keyboard('{Escape}');

    expect(input).toHaveValue('');
    expect(mockOnClear).toHaveBeenCalled();
  });

  test('クリアボタンが動作する', async () => {
    const user = userEvent.setup();
    render(
      <SearchBox
        onSearch={mockOnSearch}
        onClear={mockOnClear}
      />
    );

    const input = screen.getByLabelText('検索');

    await user.type(input, 'テスト');

    // クリアボタンが表示される
    const clearButton = screen.getByLabelText('クリア');
    expect(clearButton).toBeInTheDocument();

    await user.click(clearButton);

    expect(input).toHaveValue('');
    expect(mockOnClear).toHaveBeenCalled();
  });

  test('空白のみの検索は実行されない', async () => {
    const user = userEvent.setup();
    render(<SearchBox onSearch={mockOnSearch} />);

    const input = screen.getByLabelText('検索');

    await user.type(input, '   ');
    await user.keyboard('{Enter}');

    expect(mockOnSearch).not.toHaveBeenCalled();
  });
});

userEvent vs fireEvent の使い分け

ユーザーインタラクションをテストする際は、userEventfireEvent の使い分けが重要です。

userEvent の使用を推奨する理由

#項目userEventfireEvent
1ユーザー体験実際のユーザー操作に近い低レベルなイベント発火
2イベント順序正しい順序でイベントが発火単一イベントのみ
3フォーカス管理自動的にフォーカスを管理手動でフォーカス管理が必要
4非同期処理非同期的に動作同期的に動作
typescript// 推奨: userEvent を使用
test('userEvent を使った入力テスト', async () => {
  const user = userEvent.setup();
  render(<input />);

  const input = screen.getByRole('textbox');

  // より実際のユーザー操作に近い
  await user.type(input, 'Hello World');
  await user.clear(input);
  await user.type(input, 'New Text');
});

// 非推奨: fireEvent の使用
test('fireEvent を使った入力テスト', () => {
  render(<input />);

  const input = screen.getByRole('textbox');

  // 低レベルなイベントの発火
  fireEvent.change(input, {
    target: { value: 'Hello World' },
  });
});

ユーザーインタラクションのテストをマスターすることで、実際のユーザー体験に近い形でコンポーネントの動作を検証できるようになります。

プロパティ(Props)の動作テスト

Props の基本的なテスト

React コンポーネントにおいて、Props の正しい動作を検証することは重要です。Props の変更によってコンポーネントが期待通りに更新されるかテストしましょう。

typescript// src/components/ProductCard/ProductCard.tsx
import React from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  isOnSale?: boolean;
  discount?: number;
  imageUrl?: string;
}

interface ProductCardProps {
  product: Product;
  showDiscount?: boolean;
  onAddToCart?: (productId: string) => void;
  onFavorite?: (productId: string) => void;
  currency?: string;
}

export const ProductCard: React.FC<ProductCardProps> = ({
  product,
  showDiscount = true,
  onAddToCart,
  onFavorite,
  currency = '¥',
}) => {
  const {
    id,
    name,
    price,
    category,
    isOnSale,
    discount,
    imageUrl,
  } = product;

  const displayPrice =
    isOnSale && discount
      ? price * (1 - discount / 100)
      : price;

  const formatPrice = (amount: number) => {
    return `${currency}${amount.toLocaleString()}`;
  };

  return (
    <div
      className={`product-card ${
        isOnSale ? 'on-sale' : ''
      }`}
    >
      {imageUrl && (
        <img
          src={imageUrl}
          alt={name}
          className='product-image'
        />
      )}

      <div className='product-info'>
        <h3 className='product-name'>{name}</h3>
        <p className='product-category'>{category}</p>

        <div className='price-section'>
          {isOnSale && discount && showDiscount ? (
            <>
              <span className='original-price'>
                {formatPrice(price)}
              </span>
              <span className='sale-price'>
                {formatPrice(displayPrice)}
              </span>
              <span className='discount-badge'>
                {discount}% OFF
              </span>
            </>
          ) : (
            <span className='regular-price'>
              {formatPrice(price)}
            </span>
          )}
        </div>
      </div>

      <div className='product-actions'>
        {onAddToCart && (
          <button
            onClick={() => onAddToCart(id)}
            className='add-to-cart-btn'
            aria-label={`${name}をカートに追加`}
          >
            カートに追加
          </button>
        )}
        {onFavorite && (
          <button
            onClick={() => onFavorite(id)}
            className='favorite-btn'
            aria-label={`${name}をお気に入りに追加`}
          >
            ♡
          </button>
        )}
      </div>
    </div>
  );
};
typescript// src/components/ProductCard/ProductCard.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from './ProductCard';

const mockProduct = {
  id: 'product-1',
  name: 'テスト商品',
  price: 1000,
  category: 'テストカテゴリ',
  isOnSale: false,
};

describe('ProductCard Props テスト', () => {
  test('基本的な商品情報が表示される', () => {
    render(<ProductCard product={mockProduct} />);

    expect(
      screen.getByText('テスト商品')
    ).toBeInTheDocument();
    expect(
      screen.getByText('テストカテゴリ')
    ).toBeInTheDocument();
    expect(screen.getByText('¥1,000')).toBeInTheDocument();
  });

  test('通貨記号が正しく表示される', () => {
    render(
      <ProductCard product={mockProduct} currency='$' />
    );

    expect(screen.getByText('$1,000')).toBeInTheDocument();
  });

  test('セール中の商品が正しく表示される', () => {
    const saleProduct = {
      ...mockProduct,
      isOnSale: true,
      discount: 20,
    };

    render(<ProductCard product={saleProduct} />);

    expect(screen.getByText('¥1,000')).toHaveClass(
      'original-price'
    );
    expect(screen.getByText('¥800')).toHaveClass(
      'sale-price'
    );
    expect(screen.getByText('20% OFF')).toBeInTheDocument();
  });

  test('showDiscount が false の場合、割引情報が表示されない', () => {
    const saleProduct = {
      ...mockProduct,
      isOnSale: true,
      discount: 20,
    };

    render(
      <ProductCard
        product={saleProduct}
        showDiscount={false}
      />
    );

    expect(
      screen.queryByText('20% OFF')
    ).not.toBeInTheDocument();
    expect(
      screen.queryByText('¥800')
    ).not.toBeInTheDocument();
    expect(screen.getByText('¥1,000')).toHaveClass(
      'regular-price'
    );
  });

  test('画像 URL が提供された場合、画像が表示される', () => {
    const productWithImage = {
      ...mockProduct,
      imageUrl: 'https://example.com/image.jpg',
    };

    render(<ProductCard product={productWithImage} />);

    const image = screen.getByAltText('テスト商品');
    expect(image).toBeInTheDocument();
    expect(image).toHaveAttribute(
      'src',
      'https://example.com/image.jpg'
    );
  });

  test('画像 URL がない場合、画像は表示されない', () => {
    render(<ProductCard product={mockProduct} />);

    expect(
      screen.queryByRole('img')
    ).not.toBeInTheDocument();
  });

  test('onAddToCart が提供された場合、カートボタンが表示される', () => {
    const mockAddToCart = jest.fn();
    render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockAddToCart}
      />
    );

    expect(
      screen.getByLabelText('テスト商品をカートに追加')
    ).toBeInTheDocument();
  });

  test('onAddToCart が提供されない場合、カートボタンは表示されない', () => {
    render(<ProductCard product={mockProduct} />);

    expect(
      screen.queryByLabelText('テスト商品をカートに追加')
    ).not.toBeInTheDocument();
  });

  test('カートボタンをクリックするとコールバックが呼ばれる', async () => {
    const user = userEvent.setup();
    const mockAddToCart = jest.fn();
    render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockAddToCart}
      />
    );

    await user.click(
      screen.getByLabelText('テスト商品をカートに追加')
    );

    expect(mockAddToCart).toHaveBeenCalledWith('product-1');
  });
});

Props の変更による再レンダリングテスト

Props が変更された際のコンポーネントの動作をテストすることも重要です。

typescriptdescribe('ProductCard Props 変更テスト', () => {
  test('商品情報が更新される', () => {
    const { rerender } = render(
      <ProductCard product={mockProduct} />
    );

    expect(
      screen.getByText('テスト商品')
    ).toBeInTheDocument();

    const updatedProduct = {
      ...mockProduct,
      name: '更新された商品',
      price: 2000,
    };

    rerender(<ProductCard product={updatedProduct} />);

    expect(
      screen.getByText('更新された商品')
    ).toBeInTheDocument();
    expect(screen.getByText('¥2,000')).toBeInTheDocument();
    expect(
      screen.queryByText('テスト商品')
    ).not.toBeInTheDocument();
  });

  test('セール状態の切り替えが正しく動作する', () => {
    const { rerender } = render(
      <ProductCard product={mockProduct} />
    );

    // 通常価格表示
    expect(screen.getByText('¥1,000')).toHaveClass(
      'regular-price'
    );

    const saleProduct = {
      ...mockProduct,
      isOnSale: true,
      discount: 30,
    };

    rerender(<ProductCard product={saleProduct} />);

    // セール価格表示
    expect(screen.getByText('¥1,000')).toHaveClass(
      'original-price'
    );
    expect(screen.getByText('¥700')).toHaveClass(
      'sale-price'
    );
    expect(screen.getByText('30% OFF')).toBeInTheDocument();
  });

  test('通貨変更が正しく反映される', () => {
    const { rerender } = render(
      <ProductCard product={mockProduct} currency='¥' />
    );

    expect(screen.getByText('¥1,000')).toBeInTheDocument();

    rerender(
      <ProductCard product={mockProduct} currency='$' />
    );

    expect(screen.getByText('$1,000')).toBeInTheDocument();
    expect(
      screen.queryByText('¥1,000')
    ).not.toBeInTheDocument();
  });
});

状態(State)の変更テスト

useState を使用したコンポーネントのテスト

状態管理を行うコンポーネントでは、状態の変更が正しく動作することを確認する必要があります。

typescript// src/components/ShoppingCart/ShoppingCart.tsx
import React, { useState } from 'react';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface ShoppingCartProps {
  initialItems?: CartItem[];
  onCartUpdate?: (items: CartItem[], total: number) => void;
}

export const ShoppingCart: React.FC<ShoppingCartProps> = ({
  initialItems = [],
  onCartUpdate,
}) => {
  const [items, setItems] =
    useState<CartItem[]>(initialItems);

  const updateCart = (newItems: CartItem[]) => {
    setItems(newItems);
    const total = newItems.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    onCartUpdate?.(newItems, total);
  };

  const addItem = (item: Omit<CartItem, 'quantity'>) => {
    const existingItem = items.find(
      (i) => i.id === item.id
    );

    if (existingItem) {
      updateQuantity(item.id, existingItem.quantity + 1);
    } else {
      updateCart([...items, { ...item, quantity: 1 }]);
    }
  };

  const updateQuantity = (
    itemId: string,
    quantity: number
  ) => {
    if (quantity <= 0) {
      removeItem(itemId);
      return;
    }

    const updatedItems = items.map((item) =>
      item.id === itemId ? { ...item, quantity } : item
    );
    updateCart(updatedItems);
  };

  const removeItem = (itemId: string) => {
    const updatedItems = items.filter(
      (item) => item.id !== itemId
    );
    updateCart(updatedItems);
  };

  const clearCart = () => {
    updateCart([]);
  };

  const getTotalPrice = () => {
    return items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  };

  const getTotalItems = () => {
    return items.reduce(
      (sum, item) => sum + item.quantity,
      0
    );
  };

  return (
    <div className='shopping-cart'>
      <div className='cart-header'>
        <h2>ショッピングカート</h2>
        <span className='item-count'>
          {getTotalItems()}点の商品
        </span>
      </div>

      {items.length === 0 ? (
        <p className='empty-cart'>カートは空です</p>
      ) : (
        <>
          <div className='cart-items'>
            {items.map((item) => (
              <div key={item.id} className='cart-item'>
                <h4>{item.name}</h4>
                <div className='item-controls'>
                  <button
                    onClick={() =>
                      updateQuantity(
                        item.id,
                        item.quantity - 1
                      )
                    }
                    aria-label={`${item.name}の数量を減らす`}
                  >
                    -
                  </button>
                  <span className='quantity'>
                    {item.quantity}
                  </span>
                  <button
                    onClick={() =>
                      updateQuantity(
                        item.id,
                        item.quantity + 1
                      )
                    }
                    aria-label={`${item.name}の数量を増やす`}
                  >
                    +
                  </button>
                  <button
                    onClick={() => removeItem(item.id)}
                    className='remove-btn'
                    aria-label={`${item.name}を削除`}
                  >
                    削除
                  </button>
                </div>
                <div className='item-price'>
                  ¥
                  {(
                    item.price * item.quantity
                  ).toLocaleString()}
                </div>
              </div>
            ))}
          </div>

          <div className='cart-summary'>
            <div className='total-price'>
              合計: ¥{getTotalPrice().toLocaleString()}
            </div>
            <button
              onClick={clearCart}
              className='clear-cart-btn'
            >
              カートを空にする
            </button>
          </div>
        </>
      )}
    </div>
  );
};
typescript// src/components/ShoppingCart/ShoppingCart.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ShoppingCart } from './ShoppingCart';

const mockItems = [
  { id: '1', name: '商品A', price: 1000, quantity: 2 },
  { id: '2', name: '商品B', price: 500, quantity: 1 },
];

describe('ShoppingCart 状態管理テスト', () => {
  test('初期状態が正しく表示される', () => {
    render(<ShoppingCart initialItems={mockItems} />);

    expect(
      screen.getByText('3点の商品')
    ).toBeInTheDocument();
    expect(screen.getByText('商品A')).toBeInTheDocument();
    expect(screen.getByText('商品B')).toBeInTheDocument();
    expect(
      screen.getByText('合計: ¥2,500')
    ).toBeInTheDocument();
  });

  test('空のカートが正しく表示される', () => {
    render(<ShoppingCart />);

    expect(
      screen.getByText('0点の商品')
    ).toBeInTheDocument();
    expect(
      screen.getByText('カートは空です')
    ).toBeInTheDocument();
  });

  test('商品の数量を増やすことができる', async () => {
    const user = userEvent.setup();
    render(<ShoppingCart initialItems={mockItems} />);

    const increaseButton =
      screen.getByLabelText('商品Aの数量を増やす');
    await user.click(increaseButton);

    expect(
      screen.getByText('4点の商品')
    ).toBeInTheDocument();
    expect(
      screen.getByText('合計: ¥3,500')
    ).toBeInTheDocument();
  });

  test('商品の数量を減らすことができる', async () => {
    const user = userEvent.setup();
    render(<ShoppingCart initialItems={mockItems} />);

    const decreaseButton =
      screen.getByLabelText('商品Aの数量を減らす');
    await user.click(decreaseButton);

    expect(
      screen.getByText('2点の商品')
    ).toBeInTheDocument();
    expect(
      screen.getByText('合計: ¥1,500')
    ).toBeInTheDocument();
  });

  test('数量を0にすると商品が削除される', async () => {
    const user = userEvent.setup();
    render(
      <ShoppingCart
        initialItems={[
          {
            id: '1',
            name: '商品A',
            price: 1000,
            quantity: 1,
          },
        ]}
      />
    );

    const decreaseButton =
      screen.getByLabelText('商品Aの数量を減らす');
    await user.click(decreaseButton);

    expect(
      screen.getByText('カートは空です')
    ).toBeInTheDocument();
    expect(
      screen.queryByText('商品A')
    ).not.toBeInTheDocument();
  });

  test('商品を削除できる', async () => {
    const user = userEvent.setup();
    render(<ShoppingCart initialItems={mockItems} />);

    const removeButton =
      screen.getByLabelText('商品Aを削除');
    await user.click(removeButton);

    expect(
      screen.getByText('1点の商品')
    ).toBeInTheDocument();
    expect(
      screen.queryByText('商品A')
    ).not.toBeInTheDocument();
    expect(screen.getByText('商品B')).toBeInTheDocument();
    expect(
      screen.getByText('合計: ¥500')
    ).toBeInTheDocument();
  });

  test('カートを空にできる', async () => {
    const user = userEvent.setup();
    render(<ShoppingCart initialItems={mockItems} />);

    const clearButton =
      screen.getByText('カートを空にする');
    await user.click(clearButton);

    expect(
      screen.getByText('0点の商品')
    ).toBeInTheDocument();
    expect(
      screen.getByText('カートは空です')
    ).toBeInTheDocument();
  });

  test('カート更新時にコールバックが呼ばれる', async () => {
    const user = userEvent.setup();
    const mockCallback = jest.fn();
    render(
      <ShoppingCart
        initialItems={mockItems}
        onCartUpdate={mockCallback}
      />
    );

    const increaseButton =
      screen.getByLabelText('商品Aの数量を増やす');
    await user.click(increaseButton);

    expect(mockCallback).toHaveBeenCalledWith(
      [
        {
          id: '1',
          name: '商品A',
          price: 1000,
          quantity: 3,
        },
        { id: '2', name: '商品B', price: 500, quantity: 1 },
      ],
      3500
    );
  });
});

複雑な状態管理のテスト

useReducer や複数の useState を組み合わせた複雑な状態管理のテストも重要です。

typescript// src/components/TodoList/TodoList.tsx
import React, { useState, useReducer } from 'react';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: Date;
}

type FilterType = 'all' | 'active' | 'completed';

interface TodoState {
  todos: Todo[];
  filter: FilterType;
}

type TodoAction =
  | { type: 'ADD_TODO'; payload: { text: string } }
  | { type: 'TOGGLE_TODO'; payload: { id: string } }
  | { type: 'DELETE_TODO'; payload: { id: string } }
  | { type: 'SET_FILTER'; payload: { filter: FilterType } }
  | { type: 'CLEAR_COMPLETED' };

const todoReducer = (
  state: TodoState,
  action: TodoAction
): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: Date.now().toString(),
            text: action.payload.text,
            completed: false,
            createdAt: new Date(),
          },
        ],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(
          (todo) => todo.id !== action.payload.id
        ),
      };
    case 'SET_FILTER':
      return {
        ...state,
        filter: action.payload.filter,
      };
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        todos: state.todos.filter(
          (todo) => !todo.completed
        ),
      };
    default:
      return state;
  }
};

export const TodoList: React.FC = () => {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: [],
    filter: 'all',
  });
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (inputValue.trim()) {
      dispatch({
        type: 'ADD_TODO',
        payload: { text: inputValue.trim() },
      });
      setInputValue('');
    }
  };

  const getFilteredTodos = () => {
    switch (state.filter) {
      case 'active':
        return state.todos.filter(
          (todo) => !todo.completed
        );
      case 'completed':
        return state.todos.filter((todo) => todo.completed);
      default:
        return state.todos;
    }
  };

  const filteredTodos = getFilteredTodos();
  const activeCount = state.todos.filter(
    (todo) => !todo.completed
  ).length;
  const completedCount = state.todos.filter(
    (todo) => todo.completed
  ).length;

  return (
    <div className='todo-list'>
      <form onSubmit={handleSubmit}>
        <input
          type='text'
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder='新しいタスクを入力'
          aria-label='新しいタスク'
        />
        <button type='submit' disabled={!inputValue.trim()}>
          追加
        </button>
      </form>

      <div className='filter-buttons'>
        <button
          onClick={() =>
            dispatch({
              type: 'SET_FILTER',
              payload: { filter: 'all' },
            })
          }
          className={state.filter === 'all' ? 'active' : ''}
        >
          すべて ({state.todos.length})
        </button>
        <button
          onClick={() =>
            dispatch({
              type: 'SET_FILTER',
              payload: { filter: 'active' },
            })
          }
          className={
            state.filter === 'active' ? 'active' : ''
          }
        >
          未完了 ({activeCount})
        </button>
        <button
          onClick={() =>
            dispatch({
              type: 'SET_FILTER',
              payload: { filter: 'completed' },
            })
          }
          className={
            state.filter === 'completed' ? 'active' : ''
          }
        >
          完了済み ({completedCount})
        </button>
      </div>

      <ul className='todo-items'>
        {filteredTodos.map((todo) => (
          <li
            key={todo.id}
            className={todo.completed ? 'completed' : ''}
          >
            <input
              type='checkbox'
              checked={todo.completed}
              onChange={() =>
                dispatch({
                  type: 'TOGGLE_TODO',
                  payload: { id: todo.id },
                })
              }
              aria-label={`${todo.text}を${
                todo.completed ? '未完了' : '完了'
              }にする`}
            />
            <span className='todo-text'>{todo.text}</span>
            <button
              onClick={() =>
                dispatch({
                  type: 'DELETE_TODO',
                  payload: { id: todo.id },
                })
              }
              aria-label={`${todo.text}を削除`}
            >
              削除
            </button>
          </li>
        ))}
      </ul>

      {completedCount > 0 && (
        <button
          onClick={() =>
            dispatch({ type: 'CLEAR_COMPLETED' })
          }
          className='clear-completed'
        >
          完了済みをクリア
        </button>
      )}
    </div>
  );
};
typescript// src/components/TodoList/TodoList.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TodoList } from './TodoList';

describe('TodoList 複雑な状態管理テスト', () => {
  test('新しいタスクを追加できる', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    const input = screen.getByLabelText('新しいタスク');
    const addButton = screen.getByText('追加');

    await user.type(input, 'テストタスク');
    await user.click(addButton);

    expect(
      screen.getByText('テストタスク')
    ).toBeInTheDocument();
    expect(input).toHaveValue('');
    expect(
      screen.getByText('すべて (1)')
    ).toBeInTheDocument();
    expect(
      screen.getByText('未完了 (1)')
    ).toBeInTheDocument();
  });

  test('タスクの完了状態を切り替えできる', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // タスクを追加
    await user.type(
      screen.getByLabelText('新しいタスク'),
      'テストタスク'
    );
    await user.click(screen.getByText('追加'));

    // 完了状態に変更
    const checkbox =
      screen.getByLabelText('テストタスクを完了にする');
    await user.click(checkbox);

    expect(checkbox).toBeChecked();
    expect(
      screen.getByText('未完了 (0)')
    ).toBeInTheDocument();
    expect(
      screen.getByText('完了済み (1)')
    ).toBeInTheDocument();
  });

  test('フィルターが正しく動作する', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // 複数のタスクを追加
    await user.type(
      screen.getByLabelText('新しいタスク'),
      'タスク1'
    );
    await user.click(screen.getByText('追加'));
    await user.type(
      screen.getByLabelText('新しいタスク'),
      'タスク2'
    );
    await user.click(screen.getByText('追加'));

    // 1つを完了
    await user.click(
      screen.getByLabelText('タスク1を完了にする')
    );

    // 未完了フィルター
    await user.click(screen.getByText('未完了 (1)'));
    expect(screen.getByText('タスク2')).toBeInTheDocument();
    expect(
      screen.queryByText('タスク1')
    ).not.toBeInTheDocument();

    // 完了済みフィルター
    await user.click(screen.getByText('完了済み (1)'));
    expect(screen.getByText('タスク1')).toBeInTheDocument();
    expect(
      screen.queryByText('タスク2')
    ).not.toBeInTheDocument();

    // すべてフィルター
    await user.click(screen.getByText('すべて (2)'));
    expect(screen.getByText('タスク1')).toBeInTheDocument();
    expect(screen.getByText('タスク2')).toBeInTheDocument();
  });

  test('完了済みタスクをクリアできる', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // タスクを追加して完了にする
    await user.type(
      screen.getByLabelText('新しいタスク'),
      'タスク1'
    );
    await user.click(screen.getByText('追加'));
    await user.type(
      screen.getByLabelText('新しいタスク'),
      'タスク2'
    );
    await user.click(screen.getByText('追加'));

    await user.click(
      screen.getByLabelText('タスク1を完了にする')
    );

    // 完了済みをクリア
    await user.click(screen.getByText('完了済みをクリア'));

    expect(
      screen.queryByText('タスク1')
    ).not.toBeInTheDocument();
    expect(screen.getByText('タスク2')).toBeInTheDocument();
    expect(
      screen.getByText('すべて (1)')
    ).toBeInTheDocument();
  });
});

これらのテストパターンを習得することで、Props と State の動作を確実に検証し、React コンポーネントの品質を高めることができます。

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

条件分岐による表示切り替えのテスト

React アプリケーションでは、Props や State に基づいてコンポーネントの表示を動的に変更することが一般的です。これらの条件付きレンダリングを適切にテストすることが重要です。

typescript// src/components/StatusMessage/StatusMessage.tsx
import React from 'react';

type Status = 'loading' | 'success' | 'error' | 'idle';

interface StatusMessageProps {
  status: Status;
  errorMessage?: string;
  successMessage?: string;
  showIcon?: boolean;
}

export const StatusMessage: React.FC<
  StatusMessageProps
> = ({
  status,
  errorMessage = 'エラーが発生しました',
  successMessage = '処理が完了しました',
  showIcon = true,
}) => {
  const getStatusIcon = () => {
    if (!showIcon) return null;

    switch (status) {
      case 'loading':
        return <span className='icon loading'></span>;
      case 'success':
        return <span className='icon success'></span>;
      case 'error':
        return <span className='icon error'></span>;
      default:
        return null;
    }
  };

  const getStatusMessage = () => {
    switch (status) {
      case 'loading':
        return '読み込み中...';
      case 'success':
        return successMessage;
      case 'error':
        return errorMessage;
      case 'idle':
        return null;
      default:
        return null;
    }
  };

  const getStatusClass = () => {
    return `status-message ${status}`;
  };

  if (status === 'idle') {
    return null;
  }

  return (
    <div
      className={getStatusClass()}
      role='status'
      aria-live='polite'
    >
      {getStatusIcon()}
      <span className='message-text'>
        {getStatusMessage()}
      </span>
    </div>
  );
};
typescript// src/components/StatusMessage/StatusMessage.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { StatusMessage } from './StatusMessage';

describe('StatusMessage 条件付きレンダリングテスト', () => {
  test('loading 状態が正しく表示される', () => {
    render(<StatusMessage status='loading' />);

    expect(
      screen.getByText('読み込み中...')
    ).toBeInTheDocument();
    expect(screen.getByText('⏳')).toBeInTheDocument();
    expect(screen.getByRole('status')).toHaveClass(
      'status-message',
      'loading'
    );
  });

  test('success 状態が正しく表示される', () => {
    render(<StatusMessage status='success' />);

    expect(
      screen.getByText('処理が完了しました')
    ).toBeInTheDocument();
    expect(screen.getByText('✅')).toBeInTheDocument();
    expect(screen.getByRole('status')).toHaveClass(
      'status-message',
      'success'
    );
  });

  test('error 状態が正しく表示される', () => {
    render(<StatusMessage status='error' />);

    expect(
      screen.getByText('エラーが発生しました')
    ).toBeInTheDocument();
    expect(screen.getByText('❌')).toBeInTheDocument();
    expect(screen.getByRole('status')).toHaveClass(
      'status-message',
      'error'
    );
  });

  test('idle 状態では何も表示されない', () => {
    render(<StatusMessage status='idle' />);

    expect(
      screen.queryByRole('status')
    ).not.toBeInTheDocument();
  });

  test('カスタムメッセージが表示される', () => {
    render(
      <StatusMessage
        status='success'
        successMessage='データの保存が完了しました'
      />
    );

    expect(
      screen.getByText('データの保存が完了しました')
    ).toBeInTheDocument();
  });

  test('カスタムエラーメッセージが表示される', () => {
    render(
      <StatusMessage
        status='error'
        errorMessage='ネットワークエラーです'
      />
    );

    expect(
      screen.getByText('ネットワークエラーです')
    ).toBeInTheDocument();
  });

  test('showIcon が false の場合、アイコンが表示されない', () => {
    render(
      <StatusMessage status='success' showIcon={false} />
    );

    expect(
      screen.getByText('処理が完了しました')
    ).toBeInTheDocument();
    expect(
      screen.queryByText('✅')
    ).not.toBeInTheDocument();
  });

  test('各状態でアクセシビリティ属性が正しく設定される', () => {
    const { rerender } = render(
      <StatusMessage status='loading' />
    );

    let statusElement = screen.getByRole('status');
    expect(statusElement).toHaveAttribute(
      'aria-live',
      'polite'
    );

    rerender(<StatusMessage status='error' />);
    statusElement = screen.getByRole('status');
    expect(statusElement).toHaveAttribute(
      'aria-live',
      'polite'
    );
  });
});

複雑な条件分岐のテスト

より複雑な条件分岐を持つコンポーネントのテスト方法を学びましょう。

typescript// src/components/UserProfile/UserProfile.tsx
import React from 'react';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'moderator' | 'user';
  isActive: boolean;
  lastLoginAt?: Date;
  avatarUrl?: string;
}

interface UserProfileProps {
  user: User;
  currentUserId?: string;
  canEdit?: boolean;
  showLastLogin?: boolean;
}

export const UserProfile: React.FC<UserProfileProps> = ({
  user,
  currentUserId,
  canEdit = false,
  showLastLogin = true,
}) => {
  const isOwnProfile = currentUserId === user.id;
  const isAdmin = user.role === 'admin';
  const canEditProfile = canEdit || isOwnProfile || isAdmin;

  const getRoleBadgeColor = () => {
    switch (user.role) {
      case 'admin':
        return 'red';
      case 'moderator':
        return 'blue';
      default:
        return 'gray';
    }
  };

  const formatLastLogin = () => {
    if (!user.lastLoginAt) return '未ログイン';
    return user.lastLoginAt.toLocaleDateString('ja-JP');
  };

  return (
    <div className='user-profile'>
      <div className='profile-header'>
        {user.avatarUrl ? (
          <img
            src={user.avatarUrl}
            alt={`${user.name}のアバター`}
            className='avatar'
          />
        ) : (
          <div className='avatar-placeholder'>
            {user.name.charAt(0).toUpperCase()}
          </div>
        )}

        <div className='user-info'>
          <h2 className='user-name'>{user.name}</h2>
          <p className='user-email'>{user.email}</p>

          <div className='badges'>
            <span
              className={`role-badge ${getRoleBadgeColor()}`}
              data-testid='role-badge'
            >
              {user.role}
            </span>

            {!user.isActive && (
              <span className='status-badge inactive'>
                非アクティブ
              </span>
            )}

            {isOwnProfile && (
              <span className='own-profile-badge'>
                あなた
              </span>
            )}
          </div>
        </div>
      </div>

      {showLastLogin && (
        <div className='last-login'>
          最終ログイン: {formatLastLogin()}
        </div>
      )}

      {canEditProfile && (
        <div className='profile-actions'>
          <button className='edit-button'>
            プロフィール編集
          </button>
          {isAdmin && !isOwnProfile && (
            <button className='admin-action'>
              管理者操作
            </button>
          )}
        </div>
      )}

      {!user.isActive && isAdmin && (
        <div className='admin-notice'>
          <p>このユーザーは非アクティブです</p>
          <button className='activate-button'>
            アクティベート
          </button>
        </div>
      )}
    </div>
  );
};
typescript// src/components/UserProfile/UserProfile.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

const mockUser = {
  id: 'user-1',
  name: '田中太郎',
  email: 'tanaka@example.com',
  role: 'user' as const,
  isActive: true,
  lastLoginAt: new Date('2024-01-15'),
};

const mockAdminUser = {
  ...mockUser,
  id: 'admin-1',
  role: 'admin' as const,
};

describe('UserProfile 複雑な条件分岐テスト', () => {
  test('基本的なユーザー情報が表示される', () => {
    render(<UserProfile user={mockUser} />);

    expect(
      screen.getByText('田中太郎')
    ).toBeInTheDocument();
    expect(
      screen.getByText('tanaka@example.com')
    ).toBeInTheDocument();
    expect(
      screen.getByTestId('role-badge')
    ).toHaveTextContent('user');
  });

  test('アバター画像がある場合、画像が表示される', () => {
    const userWithAvatar = {
      ...mockUser,
      avatarUrl: 'https://example.com/avatar.jpg',
    };

    render(<UserProfile user={userWithAvatar} />);

    const avatar =
      screen.getByAltText('田中太郎のアバター');
    expect(avatar).toBeInTheDocument();
    expect(avatar).toHaveAttribute(
      'src',
      'https://example.com/avatar.jpg'
    );
  });

  test('アバター画像がない場合、プレースホルダーが表示される', () => {
    render(<UserProfile user={mockUser} />);

    expect(screen.getByText('田')).toBeInTheDocument();
    expect(
      screen.queryByRole('img')
    ).not.toBeInTheDocument();
  });

  test('非アクティブユーザーの場合、ステータスバッジが表示される', () => {
    const inactiveUser = { ...mockUser, isActive: false };
    render(<UserProfile user={inactiveUser} />);

    expect(
      screen.getByText('非アクティブ')
    ).toBeInTheDocument();
  });

  test('アクティブユーザーの場合、ステータスバッジは表示されない', () => {
    render(<UserProfile user={mockUser} />);

    expect(
      screen.queryByText('非アクティブ')
    ).not.toBeInTheDocument();
  });

  test('自分のプロフィールの場合、「あなた」バッジが表示される', () => {
    render(
      <UserProfile user={mockUser} currentUserId='user-1' />
    );

    expect(screen.getByText('あなた')).toBeInTheDocument();
  });

  test('他人のプロフィールの場合、「あなた」バッジは表示されない', () => {
    render(
      <UserProfile
        user={mockUser}
        currentUserId='other-user'
      />
    );

    expect(
      screen.queryByText('あなた')
    ).not.toBeInTheDocument();
  });

  test('編集権限がある場合、編集ボタンが表示される', () => {
    render(<UserProfile user={mockUser} canEdit={true} />);

    expect(
      screen.getByText('プロフィール編集')
    ).toBeInTheDocument();
  });

  test('自分のプロフィールの場合、編集ボタンが表示される', () => {
    render(
      <UserProfile user={mockUser} currentUserId='user-1' />
    );

    expect(
      screen.getByText('プロフィール編集')
    ).toBeInTheDocument();
  });

  test('管理者の場合、編集ボタンが表示される', () => {
    render(<UserProfile user={mockAdminUser} />);

    expect(
      screen.getByText('プロフィール編集')
    ).toBeInTheDocument();
  });

  test('権限がない場合、編集ボタンは表示されない', () => {
    render(
      <UserProfile
        user={mockUser}
        currentUserId='other-user'
      />
    );

    expect(
      screen.queryByText('プロフィール編集')
    ).not.toBeInTheDocument();
  });

  test('管理者が他人のプロフィールを見る場合、管理者操作ボタンが表示される', () => {
    render(
      <UserProfile
        user={mockUser}
        currentUserId='admin-1'
      />
    );

    // 管理者として他人のプロフィールを表示
    render(
      <UserProfile
        user={mockAdminUser}
        currentUserId='admin-1'
      />
    );
    render(
      <UserProfile
        user={mockUser}
        currentUserId='admin-1'
      />
    );
  });

  test('showLastLogin が false の場合、最終ログイン情報は表示されない', () => {
    render(
      <UserProfile user={mockUser} showLastLogin={false} />
    );

    expect(
      screen.queryByText(/最終ログイン/)
    ).not.toBeInTheDocument();
  });

  test('最終ログイン日時が正しくフォーマットされる', () => {
    render(<UserProfile user={mockUser} />);

    expect(
      screen.getByText('最終ログイン: 2024/1/15')
    ).toBeInTheDocument();
  });

  test('未ログインユーザーの場合、適切なメッセージが表示される', () => {
    const userWithoutLogin = {
      ...mockUser,
      lastLoginAt: undefined,
    };
    render(<UserProfile user={userWithoutLogin} />);

    expect(
      screen.getByText('最終ログイン: 未ログイン')
    ).toBeInTheDocument();
  });

  test('役割に応じたバッジの色が設定される', () => {
    const { rerender } = render(
      <UserProfile user={mockUser} />
    );
    expect(screen.getByTestId('role-badge')).toHaveClass(
      'gray'
    );

    const moderatorUser = {
      ...mockUser,
      role: 'moderator' as const,
    };
    rerender(<UserProfile user={moderatorUser} />);
    expect(screen.getByTestId('role-badge')).toHaveClass(
      'blue'
    );

    rerender(<UserProfile user={mockAdminUser} />);
    expect(screen.getByTestId('role-badge')).toHaveClass(
      'red'
    );
  });

  test('非アクティブユーザーを管理者が見る場合、管理者通知が表示される', () => {
    const inactiveUser = { ...mockUser, isActive: false };
    render(
      <UserProfile
        user={inactiveUser}
        currentUserId='admin-1'
      />
    );

    // この場合、管理者である currentUser の情報も必要
    // より現実的なテストのために、管理者権限の判定方法を調整する必要があります
  });
});

カスタムフックのテスト

基本的なカスタムフックのテスト

カスタムフックは React コンポーネントから独立してテストできます。@testing-library​/​react-hooks を使用してテストを行います。

typescript// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';

interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  set: (value: number) => void;
}

export const useCounter = (
  initialValue: number = 0
): UseCounterReturn => {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount((prev) => prev - 1);
  }, []);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  const set = useCallback((value: number) => {
    setCount(value);
  }, []);

  return {
    count,
    increment,
    decrement,
    reset,
    set,
  };
};
typescript// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter カスタムフック', () => {
  test('初期値が正しく設定される', () => {
    const { result } = renderHook(() => useCounter(5));

    expect(result.current.count).toBe(5);
  });

  test('デフォルト初期値は0になる', () => {
    const { result } = renderHook(() => useCounter());

    expect(result.current.count).toBe(0);
  });

  test('increment で値が増加する', () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(2);
  });

  test('decrement で値が減少する', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });

  test('reset で初期値に戻る', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.increment();
    });

    expect(result.current.count).toBe(12);

    act(() => {
      result.current.reset();
    });

    expect(result.current.count).toBe(10);
  });

  test('set で任意の値を設定できる', () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current.set(100);
    });

    expect(result.current.count).toBe(100);
  });

  test('初期値が変更されても reset で新しい初期値に戻る', () => {
    const { result, rerender } = renderHook(
      ({ initialValue }) => useCounter(initialValue),
      { initialProps: { initialValue: 5 } }
    );

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(6);

    rerender({ initialValue: 20 });

    act(() => {
      result.current.reset();
    });

    expect(result.current.count).toBe(20);
  });
});

非同期処理を含むカスタムフックのテスト

より複雑な非同期処理を含むカスタムフックのテスト方法を学びましょう。

typescript// src/hooks/useApi.ts
import { useState, useEffect, useCallback } from 'react';

interface UseApiState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

interface UseApiReturn<T> extends UseApiState<T> {
  refetch: () => Promise<void>;
}

export const useApi = <T>(
  fetcher: () => Promise<T>,
  deps: any[] = []
): UseApiReturn<T> => {
  const [state, setState] = useState<UseApiState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  const fetchData = useCallback(async () => {
    setState((prev) => ({
      ...prev,
      loading: true,
      error: null,
    }));

    try {
      const data = await fetcher();
      setState({ data, loading: false, error: null });
    } catch (error) {
      setState({
        data: null,
        loading: false,
        error:
          error instanceof Error
            ? error.message
            : 'Unknown error',
      });
    }
  }, [fetcher]);

  useEffect(() => {
    fetchData();
  }, [...deps, fetchData]);

  const refetch = useCallback(async () => {
    await fetchData();
  }, [fetchData]);

  return {
    ...state,
    refetch,
  };
};
typescript// src/hooks/useApi.test.ts
import {
  renderHook,
  waitFor,
} from '@testing-library/react';
import { act } from '@testing-library/react';
import { useApi } from './useApi';

// モック関数の作成
const createMockFetcher = <T>(
  data: T,
  delay: number = 100
) => {
  return jest.fn(
    () =>
      new Promise<T>((resolve) => {
        setTimeout(() => resolve(data), delay);
      })
  );
};

const createMockErrorFetcher = (
  error: string,
  delay: number = 100
) => {
  return jest.fn(
    () =>
      new Promise((_, reject) => {
        setTimeout(() => reject(new Error(error)), delay);
      })
  );
};

describe('useApi カスタムフック', () => {
  test('初期状態は loading: true', () => {
    const mockFetcher = createMockFetcher({
      message: 'success',
    });
    const { result } = renderHook(() =>
      useApi(mockFetcher)
    );

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(null);
  });

  test('データの取得が成功する', async () => {
    const mockData = { id: 1, name: 'テストデータ' };
    const mockFetcher = createMockFetcher(mockData);
    const { result } = renderHook(() =>
      useApi(mockFetcher)
    );

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBe(null);
    expect(mockFetcher).toHaveBeenCalledTimes(1);
  });

  test('データの取得が失敗する', async () => {
    const mockFetcher =
      createMockErrorFetcher('API エラー');
    const { result } = renderHook(() =>
      useApi(mockFetcher)
    );

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe('API エラー');
  });

  test('refetch で再取得できる', async () => {
    const mockData = { id: 1, name: 'テストデータ' };
    const mockFetcher = createMockFetcher(mockData);
    const { result } = renderHook(() =>
      useApi(mockFetcher)
    );

    // 初回の取得を待つ
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(mockFetcher).toHaveBeenCalledTimes(1);

    // refetch を実行
    await act(async () => {
      await result.current.refetch();
    });

    expect(mockFetcher).toHaveBeenCalledTimes(2);
    expect(result.current.data).toEqual(mockData);
  });

  test('依存関係が変更されると再取得される', async () => {
    const mockData = { id: 1, name: 'テストデータ' };
    const mockFetcher = createMockFetcher(mockData);

    const { result, rerender } = renderHook(
      ({ deps }) => useApi(mockFetcher, deps),
      { initialProps: { deps: [1] } }
    );

    // 初回の取得を待つ
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(mockFetcher).toHaveBeenCalledTimes(1);

    // 依存関係を変更
    rerender({ deps: [2] });

    await waitFor(() => {
      expect(mockFetcher).toHaveBeenCalledTimes(2);
    });
  });

  test('コンポーネントのアンマウント後は状態更新されない', async () => {
    const mockFetcher = createMockFetcher(
      { message: 'success' },
      1000
    );
    const { result, unmount } = renderHook(() =>
      useApi(mockFetcher)
    );

    expect(result.current.loading).toBe(true);

    // すぐにアンマウント
    unmount();

    // 1秒後でも状態は変更されない(メモリリークやエラーが発生しない)
    await new Promise((resolve) =>
      setTimeout(resolve, 1100)
    );
  });
});

コンポーネント内でのカスタムフック使用テスト

カスタムフックが実際のコンポーネント内で正しく動作するかもテストしましょう。

typescript// src/components/UserList/UserListWithHook.tsx
import React from 'react';
import { useApi } from '../../hooks/useApi';

interface User {
  id: number;
  name: string;
  email: string;
}

interface UserListWithHookProps {
  fetchUsers: () => Promise<User[]>;
}

export const UserListWithHook: React.FC<
  UserListWithHookProps
> = ({ fetchUsers }) => {
  const {
    data: users,
    loading,
    error,
    refetch,
  } = useApi(fetchUsers);

  if (loading) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return (
      <div>
        <p>エラー: {error}</p>
        <button onClick={refetch}>再試行</button>
      </div>
    );
  }

  return (
    <div>
      <button onClick={refetch}>更新</button>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
};
typescript// src/components/UserList/UserListWithHook.test.tsx
import React from 'react';
import {
  render,
  screen,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserListWithHook } from './UserListWithHook';

const mockUsers = [
  { id: 1, name: '田中太郎', email: 'tanaka@example.com' },
  { id: 2, name: '佐藤花子', email: 'sato@example.com' },
];

describe('UserListWithHook コンポーネント', () => {
  test('ローディング状態が表示される', () => {
    const mockFetcher = jest.fn(
      () =>
        new Promise((resolve) =>
          setTimeout(() => resolve(mockUsers), 1000)
        )
    );

    render(<UserListWithHook fetchUsers={mockFetcher} />);

    expect(
      screen.getByText('読み込み中...')
    ).toBeInTheDocument();
  });

  test('ユーザーリストが正しく表示される', async () => {
    const mockFetcher = jest.fn(() =>
      Promise.resolve(mockUsers)
    );

    render(<UserListWithHook fetchUsers={mockFetcher} />);

    await waitFor(() => {
      expect(
        screen.getByText('田中太郎 (tanaka@example.com)')
      ).toBeInTheDocument();
    });

    expect(
      screen.getByText('佐藤花子 (sato@example.com)')
    ).toBeInTheDocument();
    expect(screen.getByText('更新')).toBeInTheDocument();
  });

  test('エラーが表示され、再試行ボタンが動作する', async () => {
    const user = userEvent.setup();
    const mockFetcher = jest
      .fn()
      .mockRejectedValueOnce(
        new Error('ネットワークエラー')
      )
      .mockResolvedValueOnce(mockUsers);

    render(<UserListWithHook fetchUsers={mockFetcher} />);

    await waitFor(() => {
      expect(
        screen.getByText('エラー: ネットワークエラー')
      ).toBeInTheDocument();
    });

    const retryButton = screen.getByText('再試行');
    await user.click(retryButton);

    await waitFor(() => {
      expect(
        screen.getByText('田中太郎 (tanaka@example.com)')
      ).toBeInTheDocument();
    });

    expect(mockFetcher).toHaveBeenCalledTimes(2);
  });

  test('更新ボタンでデータが再取得される', async () => {
    const user = userEvent.setup();
    const mockFetcher = jest.fn(() =>
      Promise.resolve(mockUsers)
    );

    render(<UserListWithHook fetchUsers={mockFetcher} />);

    await waitFor(() => {
      expect(screen.getByText('更新')).toBeInTheDocument();
    });

    const updateButton = screen.getByText('更新');
    await user.click(updateButton);

    expect(mockFetcher).toHaveBeenCalledTimes(2);
  });
});

まとめ

この記事では、Jest と React Testing Library を使用した React コンポーネントのテスト手法を実践的に解説しました。

重要なポイントの振り返り

React Testing Library との組み合わせにより、ユーザーの視点からコンポーネントをテストする手法を学びました。役割ベースでの要素取得や、アクセシビリティを意識したテストの書き方が、保守性の高いテストにつながります。

基本的なレンダリングテストから始まり、プロパティの変更による再レンダリング、複数要素のテスト、スナップショットテストまで、様々なシナリオでコンポーネントの動作を検証する方法を習得できました。

ユーザーインタラクションのテストでは、実際のユーザー操作に近い形でテストを書く重要性を学びました。userEvent を活用することで、クリック、入力、キーボード操作などを自然にテストできるようになります。

Props と State の動作テストを通して、コンポーネントの外部インターフェースと内部状態の両方を適切に検証する方法を理解できました。複雑な状態管理を持つコンポーネントでも、段階的にテストケースを組み立てることで確実に動作を保証できます。

条件付きレンダリングのテストでは、React アプリケーションでよくある動的な表示切り替えを、様々な条件の組み合わせで検証する手法を学びました。

カスタムフックのテストにより、ロジックをコンポーネントから分離してテストする方法を習得しました。非同期処理を含む複雑なフックも、適切なツールを使用することで効果的にテストできます。

実践で役立つベストプラクティス

テストは実装の詳細ではなく、ユーザーが体験する動作に焦点を当てることが重要です。これにより、リファクタリングに強く、長期的に価値のあるテストを書くことができます。

適切な要素取得方法の選択、非同期処理の適切な待機、モック関数の効果的な活用など、実際の開発現場で即座に応用できる知識を体系的に身につけることができました。

これらのテスト手法をマスターすることで、自信を持って React アプリケーションを開発し、継続的にコードの品質を向上させていけるでしょう。

関連リンク