Reactコンポーネントのテスト|初心者向け基本の書き方

Reactコンポーネントのテスト方法を初心者向けに詳しく解説。Jest・React Testing Libraryの基本的な使い方から、実践的なテストパターン、モック・スパイの活用まで分かりやすく説明します。

Learning Next 運営
34 分で読めます

みなさん、Reactコンポーネントのテストを書いたことはありますか?

「テストは大切だけど、何から始めればいいの?」 「どんなテストを書けばいいか分からない」

こんな悩みを抱えている方も多いのではないでしょうか。

テストは、コードの品質を保つために欠かせない要素です。 この記事では、Reactコンポーネントのテストを基本から分かりやすく解説します。

実際のコード例とともに、効果的なテストの書き方をお伝えしますので、ぜひ参考にしてください。

Reactテストの基本概念

まず、Reactのテストについて基本的な概念を理解していきましょう。

テストの種類

Reactアプリケーションでは、主に以下のテストを書きます。

  • 単体テスト: 個別のコンポーネントの動作を検証
  • 統合テスト: 複数のコンポーネントが連携する機能を検証
  • E2Eテスト: ユーザーの操作フローを検証

このうち、単体テストが最も基本的で重要なテストです。

使用するツール

一般的なReactプロジェクトでは、以下のツールを使用します。

// package.json の依存関係例
{
  "devDependencies": {
    "@testing-library/react": "^13.0.0",
    "@testing-library/jest-dom": "^5.16.0",
    "@testing-library/user-event": "^14.0.0",
    "jest": "^27.0.0"
  }
}

このpackage.jsonでは、テストに必要なライブラリを定義しています。

各ツールの役割を詳しく見てみましょう。

  • Jest: テストランナー(テスト実行環境)
  • React Testing Library: Reactコンポーネントのテストライブラリ
  • @testing-library/jest-dom: JestのマッチャーをDOMに特化したもの

これらのツールを組み合わせることで、効率的にテストを書けます。

基本的なテストの書き方

それでは、シンプルなコンポーネントのテストから始めてみましょう。

Hello Worldコンポーネントのテスト

まず、基本的なコンポーネントを作成します。

// HelloWorld.jsx
function HelloWorld({ name }) {
  return <h1>Hello, {name}!</h1>;
}

export default HelloWorld;

このHelloWorldコンポーネントは、nameというpropsを受け取ってメッセージを表示します。 とてもシンプルなコンポーネントですね。

次に、このコンポーネントのテストを書いてみましょう。

// HelloWorld.test.jsx
import { render, screen } from '@testing-library/react';
import HelloWorld from './HelloWorld';

test('名前を表示する', () => {
  // コンポーネントをレンダリング
  render(<HelloWorld name="太郎" />);
  
  // テキストが表示されているかを確認
  expect(screen.getByText('Hello, 太郎!')).toBeInTheDocument();
});

test('propsが正しく渡される', () => {
  render(<HelloWorld name="花子" />);
  
  // 異なる名前でもテストが通ることを確認
  expect(screen.getByText('Hello, 花子!')).toBeInTheDocument();
});

このテストコードでは、以下の処理を行っています。

まず、renderでコンポーネントをテスト環境にレンダリングします。 次に、screen.getByTextで特定のテキストが表示されているかを確認します。

toBeInTheDocumentは、要素がDOMに存在するかをチェックするマッチャーです。

より実践的なコンポーネントのテスト

今度は、もう少し複雑なコンポーネントを見てみましょう。

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

function Counter({ initialValue = 0 }) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(initialValue);
  
  return (
    <div>
      <h2>カウンター</h2>
      <p data-testid="count-display">現在の値: {count}</p>
      <button onClick={increment}>増加</button>
      <button onClick={decrement}>減少</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

export default Counter;

このカウンターコンポーネントでは、以下の機能を実装しています。

useStatecountという状態を管理しています。 incrementdecrementresetの3つの関数でカウンターの値を操作します。

data-testid属性を使って、テストで要素を簡単に取得できるようにしています。

次に、このコンポーネントのテストを書いてみましょう。

// Counter.test.jsx
import { render, screen } 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-display')).toHaveTextContent('現在の値: 5');
  });
  
  test('増加ボタンをクリックすると値が増加する', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} />);
    
    const incrementButton = screen.getByText('増加');
    await user.click(incrementButton);
    
    expect(screen.getByTestId('count-display')).toHaveTextContent('現在の値: 1');
  });
  
  test('減少ボタンをクリックすると値が減少する', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={5} />);
    
    const decrementButton = screen.getByText('減少');
    await user.click(decrementButton);
    
    expect(screen.getByTestId('count-display')).toHaveTextContent('現在の値: 4');
  });
  
  test('リセットボタンをクリックすると初期値に戻る', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={10} />);
    
    // まず値を変更
    const incrementButton = screen.getByText('増加');
    await user.click(incrementButton);
    expect(screen.getByTestId('count-display')).toHaveTextContent('現在の値: 11');
    
    // リセットボタンをクリック
    const resetButton = screen.getByText('リセット');
    await user.click(resetButton);
    
    expect(screen.getByTestId('count-display')).toHaveTextContent('現在の値: 10');
  });
});

このテストコードでは、ユーザーの操作をシミュレートしています。

userEvent.setup()でユーザーイベントを初期化します。 user.click()でボタンクリックをシミュレートします。

各テストで、ボタンクリック後の状態が正しく変化することを確認しています。

イベントハンドリングのテスト

次に、ユーザーのインタラクションをテストする方法を詳しく見てみましょう。

フォームコンポーネントのテスト

実際のアプリケーションでよく使われるフォームコンポーネントを作成します。

// LoginForm.jsx
import { useState } from 'react';

function LoginForm({ onSubmit }) {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    if (!username || !password) {
      setError('ユーザー名とパスワードを入力してください');
      return;
    }
    
    setError('');
    onSubmit({ username, password });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">ユーザー名</label>
        <input
          id="username"
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      
      <div>
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      
      {error && <p data-testid="error-message">{error}</p>}
      
      <button type="submit">ログイン</button>
    </form>
  );
}

export default LoginForm;

このログインフォームでは、以下の機能を実装しています。

usernamepasswordの入力値を管理しています。 バリデーション処理で、空の場合にエラーメッセージを表示します。

フォーム送信時にonSubmitコールバックを呼び出します。

それでは、このフォームのテストを書いてみましょう。

// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginFormコンポーネント', () => {
  test('フォームの入力と送信が正しく動作する', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = jest.fn();
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    // 入力フィールドを取得
    const usernameInput = screen.getByLabelText('ユーザー名');
    const passwordInput = screen.getByLabelText('パスワード');
    const submitButton = screen.getByText('ログイン');
    
    // フォームに入力
    await user.type(usernameInput, 'testuser');
    await user.type(passwordInput, 'password123');
    
    // フォームを送信
    await user.click(submitButton);
    
    // onSubmitが正しい引数で呼ばれることを確認
    expect(mockOnSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'password123'
    });
  });
  
  test('必須フィールドが空の場合エラーメッセージが表示される', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = jest.fn();
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    const submitButton = screen.getByText('ログイン');
    
    // 何も入力せずに送信
    await user.click(submitButton);
    
    // エラーメッセージが表示されることを確認
    expect(screen.getByTestId('error-message')).toHaveTextContent(
      'ユーザー名とパスワードを入力してください'
    );
    
    // onSubmitが呼ばれないことを確認
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
});

このテストコードでは、以下の重要なポイントを確認しています。

jest.fn()でモック関数を作成し、コールバックの呼び出しを検証します。 user.type()でテキスト入力をシミュレートします。

正常な場合とエラーの場合の両方をテストしています。

非同期処理のテスト

APIコールなどの非同期処理を含むコンポーネントのテスト方法を見てみましょう。

データ取得コンポーネントのテスト

実際のアプリケーションでは、APIからデータを取得することが多いです。

// UserProfile.jsx
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    if (!userId) return;
    
    fetchUser(userId)
      .then(userData => {
        setUser(userData);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) return <div data-testid="loading">読み込み中...</div>;
  if (error) return <div data-testid="error">エラー: {error}</div>;
  if (!user) return <div data-testid="no-user">ユーザーが見つかりません</div>;
  
  return (
    <div data-testid="user-profile">
      <h2>{user.name}</h2>
      <p>メール: {user.email}</p>
      <p>年齢: {user.age}</p>
    </div>
  );
}

// API関数(実際の実装)
async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) {
    throw new Error('ユーザーの取得に失敗しました');
  }
  return response.json();
}

export default UserProfile;

このUserProfileコンポーネントでは、以下の処理を行っています。

useEffectでコンポーネントマウント時にAPIを呼び出します。 loadingerroruserの状態に応じて表示を切り替えます。

fetchUser関数で実際のAPI呼び出しを行います。

では、このコンポーネントのテストを書いてみましょう。

// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// fetchUser 関数をモック化
jest.mock('./api', () => ({
  fetchUser: jest.fn()
}));

// モック化された関数をインポート
import { fetchUser } from './api';

describe('UserProfileコンポーネント', () => {
  beforeEach(() => {
    // 各テスト前にモックをリセット
    jest.clearAllMocks();
  });
  
  test('ユーザーデータが正常に表示される', async () => {
    const mockUser = {
      id: 1,
      name: '田中太郎',
      email: 'tanaka@example.com',
      age: 30
    };
    
    // fetchUser がモックデータを返すように設定
    fetchUser.mockResolvedValue(mockUser);
    
    render(<UserProfile userId={1} />);
    
    // 最初はローディング状態
    expect(screen.getByTestId('loading')).toBeInTheDocument();
    
    // ユーザーデータが表示されるまで待機
    await waitFor(() => {
      expect(screen.getByTestId('user-profile')).toBeInTheDocument();
    });
    
    // ユーザー情報が正しく表示されているか確認
    expect(screen.getByText('田中太郎')).toBeInTheDocument();
    expect(screen.getByText('メール: tanaka@example.com')).toBeInTheDocument();
    expect(screen.getByText('年齢: 30')).toBeInTheDocument();
  });
  
  test('API エラーが発生した場合エラーメッセージが表示される', async () => {
    // fetchUser がエラーを投げるように設定
    fetchUser.mockRejectedValue(new Error('ネットワークエラー'));
    
    render(<UserProfile userId={1} />);
    
    // エラーメッセージが表示されるまで待機
    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument();
    });
    
    expect(screen.getByText('エラー: ネットワークエラー')).toBeInTheDocument();
  });
});

このテストコードでは、以下の技術を活用しています。

jest.mock()で外部のAPI関数をモック化しています。 mockResolvedValueで正常なレスポンスを、mockRejectedValueでエラーをシミュレートします。

waitForを使って、非同期処理の完了を待機します。

カスタムフックのテスト

カスタムフックのテスト方法も確認してみましょう。

カスタムフックの実装とテスト

まず、シンプルなカスタムフックを作成します。

// useCounter.js
import { useState } from 'react';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  const reset = () => setCount(initialValue);
  
  return {
    count,
    increment,
    decrement,
    reset
  };
}

export default useCounter;

このuseCounterフックでは、以下の機能を提供しています。

countの状態管理と、それを操作する関数を返します。 incrementdecrementresetの3つの操作が可能です。

次に、このカスタムフックのテストを書いてみましょう。

// useCounter.test.js
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('increment が正しく動作する', () => {
    const { result } = renderHook(() => useCounter(0));
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  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);
  });
});

カスタムフックのテストでは、以下のAPIを使用します。

renderHookでカスタムフックをテスト環境で実行します。 actでフックの状態更新を同期的に実行します。

result.currentで現在のフックの戻り値にアクセスできます。

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

効果的なテストを書くためのベストプラクティスを見てみましょう。

テストの構造化

テストを整理して読みやすくする方法をご紹介します。

// 良いテストの構造例
describe('UserListコンポーネント', () => {
  // 共通のセットアップ
  const mockUsers = [
    { id: 1, name: '田中太郎', age: 30 },
    { id: 2, name: '山田花子', age: 25 }
  ];
  
  beforeEach(() => {
    // 各テスト前の共通処理
    jest.clearAllMocks();
  });
  
  describe('正常な状態', () => {
    test('ユーザーリストが表示される', () => {
      // Arrange(準備)
      render(<UserList users={mockUsers} />);
      
      // Act(実行) - この場合は自動的に実行される
      
      // Assert(検証)
      expect(screen.getByText('田中太郎')).toBeInTheDocument();
      expect(screen.getByText('山田花子')).toBeInTheDocument();
    });
  });
  
  describe('エラー状態', () => {
    test('ユーザーが空の場合メッセージが表示される', () => {
      render(<UserList users={[]} />);
      
      expect(screen.getByText('ユーザーがいません')).toBeInTheDocument();
    });
  });
});

この構造化されたテストでは、以下のポイントを意識しています。

describeでテストをグループ化し、整理しています。 beforeEachで共通のセットアップ処理を行います。

Arrange-Act-Assertパターンでテストを構造化しています。

適切なセレクターの使用

テストの保守性を高めるため、適切なセレクターを使用しましょう。

// セレクターの優先順位(推奨順)
test('適切なセレクターの使用例', () => {
  render(<MyComponent />);
  
  // 1. アクセシビリティ属性(最推奨)
  screen.getByRole('button', { name: '送信' });
  screen.getByLabelText('メールアドレス');
  
  // 2. data-testid(開発者が明示的に設定)
  screen.getByTestId('user-profile');
  
  // 3. テキスト内容
  screen.getByText('こんにちは');
  
  // 4. その他(なるべく避ける)
  screen.getByClassName('my-class'); // 避けるべき
});

このセレクターの優先順位は、以下の理由に基づいています。

アクセシビリティ属性は、実際のユーザーの使用方法に近いです。 data-testidは、テスト専用の属性なので変更されにくいです。

CSSクラス名などは変更されやすいので、避けることをおすすめします。

モックの適切な使用

外部依存をモックする方法を見てみましょう。

// 適切なモックの例
describe('APIを使用するコンポーネント', () => {
  // モジュール全体をモック
  jest.mock('../api/userApi');
  
  test('API成功時の処理', async () => {
    // 特定の戻り値を設定
    userApi.fetchUser.mockResolvedValue({
      id: 1,
      name: 'テストユーザー'
    });
    
    render(<UserComponent userId={1} />);
    
    await waitFor(() => {
      expect(screen.getByText('テストユーザー')).toBeInTheDocument();
    });
    
    // APIが正しい引数で呼ばれたか確認
    expect(userApi.fetchUser).toHaveBeenCalledWith(1);
  });
});

モックを使用することで、以下のメリットがあります。

外部APIに依存せず、テストが安定して実行できます。 様々なシナリオ(成功、失敗)を簡単にテストできます。

テストの実行速度も向上します。

よくある間違いと対処法

テストを書く際によくある間違いを確認して、適切な対処法を学びましょう。

間違い1: 実装の詳細をテストする

実装の詳細ではなく、ユーザーに見える結果をテストしましょう。

// ❌ 間違った例:内部のstate変更をテスト
test('内部stateのテスト(悪い例)', () => {
  const wrapper = mount(<Counter />);
  
  // 内部の実装詳細をテストしている
  expect(wrapper.state('count')).toBe(0);
  
  wrapper.find('button').simulate('click');
  expect(wrapper.state('count')).toBe(1);
});

// ✅ 正しい例:ユーザーに見える結果をテスト
test('カウンターの動作(良い例)', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  
  // ユーザーに見える結果をテスト
  expect(screen.getByText('0')).toBeInTheDocument();
  
  await user.click(screen.getByText('増加'));
  expect(screen.getByText('1')).toBeInTheDocument();
});

間違った例では、コンポーネントの内部状態を直接テストしています。 これは実装の詳細なので、変更されやすく、テストが壊れやすくなります。

正しい例では、ユーザーが実際に見る結果をテストしています。 これにより、リファクタリングしてもテストが壊れにくくなります。

間違い2: 非同期処理の適切でない待機

非同期処理では、適切に完了を待機する必要があります。

// ❌ 間違った例:適切でない待機
test('非同期処理(悪い例)', () => {
  render(<AsyncComponent />);
  
  // 非同期処理の完了を待たずにテスト
  expect(screen.getByText('データ')).toBeInTheDocument(); // エラーになる可能性
});

// ✅ 正しい例:適切な待機
test('非同期処理(良い例)', async () => {
  render(<AsyncComponent />);
  
  // 要素が表示されるまで待機
  await waitFor(() => {
    expect(screen.getByText('データ')).toBeInTheDocument();
  });
});

間違った例では、非同期処理が完了する前にテストを実行しています。 これにより、テストが不安定になり、時々失敗することがあります。

正しい例では、waitForを使って適切に待機しています。 これにより、テストが安定して実行されます。

まとめ

Reactコンポーネントのテストについて、重要なポイントをまとめます。

テストの基本原則

  • ユーザーの視点でテストを書くことが最も重要です
  • 適切なセレクターを使用してテストの保守性を高めましょう
  • 非同期処理は適切に待機することで安定したテストになります
  • モックを活用して外部依存を制御しましょう

効果的なテストの書き方

テストは最初は難しく感じるかもしれませんが、慣れてくると開発の安心感が大幅に向上します。 コードの品質も向上し、リファクタリングも安心して行えるようになります。

まずは簡単なコンポーネントから始めて、徐々に複雑なテストにチャレンジしていきましょう。

実際のプロジェクトでこれらのテスト手法を活用して、より良いReactアプリケーションを作ってみてください。

関連記事