React

Reactのテスト入門|コンポーネントをテストする

Reactコンポーネントのテストは、アプリケーションの品質を保証するために重要です。React Testing LibraryとJestを使えば、ユーザーの操作に近い形でコンポーネントをテストできます。ここでは、Reactのテストの基本的な書き方を解説します。

基本的な使い方

TodoList.js
import React, { useState } from 'react';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const [error, setError] = useState('');

  const handleInputChange = (e) => {
    setInputValue(e.target.value);
    setError('');
  };

  const handleAddTodo = () => {
    if (!inputValue.trim()) {
      setError('タスクを入力してください');
      return;
    }

    const newTodo = {
      id: Date.now(),
      text: inputValue,
      completed: false
    };

    setTodos([...todos, newTodo]);
    setInputValue('');
  };

  const handleToggleTodo = (id) => {
    const updatedTodos = todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    );
    setTodos(updatedTodos);
  };

  const handleDeleteTodo = (id) => {
    const filteredTodos = todos.filter(todo => todo.id !== id);
    setTodos(filteredTodos);
  };

  return (
    <div>
      <h1>Todoリスト</h1>
      
      <div>
        <input
          type="text"
          value={inputValue}
          onChange={handleInputChange}
          placeholder="新しいタスクを入力"
          data-testid="todo-input"
        />
        <button 
          onClick={handleAddTodo}
          data-testid="add-button"
        >
          追加
        </button>
      </div>
      
      {error && <p data-testid="error-message" style={{ color: 'red' }}>{error}</p>}
      
      <ul data-testid="todo-list">
        {todos.length === 0 ? (
          <li data-testid="empty-message">タスクがありません</li>
        ) : (
          todos.map(todo => (
            <li 
              key={todo.id} 
              data-testid={`todo-item-${todo.id}`}
              style={{ 
                textDecoration: todo.completed ? 'line-through' : 'none',
                margin: '10px 0'
              }}
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => handleToggleTodo(todo.id)}
                data-testid={`todo-checkbox-${todo.id}`}
              />
              <span data-testid={`todo-text-${todo.id}`}>{todo.text}</span>
              <button 
                onClick={() => handleDeleteTodo(todo.id)}
                data-testid={`todo-delete-${todo.id}`}
                style={{ marginLeft: '10px' }}
              >
                削除
              </button>
            </li>
          ))
        )}
      </ul>
    </div>
  );
}

export default TodoList;
TodoList.test.js
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TodoList from './TodoList';

describe('TodoListコンポーネント', () => {
  test('初期状態では空のメッセージが表示される', () => {
    render(<TodoList />);
    
    // 空のメッセージが表示されていることを確認
    expect(screen.getByTestId('empty-message')).toBeInTheDocument();
    expect(screen.getByTestId('empty-message')).toHaveTextContent('タスクがありません');
  });
  
  test('新しいタスクを追加できる', () => {
    render(<TodoList />);
    
    // 入力フィールドにテキストを入力
    const input = screen.getByTestId('todo-input');
    fireEvent.change(input, { target: { value: 'テストタスク' } });
    
    // 追加ボタンをクリック
    const addButton = screen.getByTestId('add-button');
    fireEvent.click(addButton);
    
    // タスクが追加されたことを確認
    const todoText = screen.getByText('テストタスク');
    expect(todoText).toBeInTheDocument();
    
    // 入力フィールドがクリアされたことを確認
    expect(input.value).toBe('');
    
    // 空のメッセージが表示されなくなったことを確認
    expect(screen.queryByTestId('empty-message')).not.toBeInTheDocument();
  });
  
  test('空のタスクを追加しようとするとエラーメッセージが表示される', () => {
    render(<TodoList />);
    
    // 空の状態で追加ボタンをクリック
    const addButton = screen.getByTestId('add-button');
    fireEvent.click(addButton);
    
    // エラーメッセージが表示されることを確認
    const errorMessage = screen.getByTestId('error-message');
    expect(errorMessage).toBeInTheDocument();
    expect(errorMessage).toHaveTextContent('タスクを入力してください');
  });
  
  test('タスクの完了状態を切り替えることができる', () => {
    render(<TodoList />);
    
    // タスクを追加
    const input = screen.getByTestId('todo-input');
    fireEvent.change(input, { target: { value: 'テストタスク' } });
    
    const addButton = screen.getByTestId('add-button');
    fireEvent.click(addButton);
    
    // チェックボックスをクリック
    const todoItem = screen.getByTestId(/^todo-item-/);
    const checkbox = screen.getByTestId(/^todo-checkbox-/);
    
    // 初期状態では取り消し線がないことを確認
    expect(todoItem).not.toHaveStyle('text-decoration: line-through');
    
    // チェックボックスをクリック
    fireEvent.click(checkbox);
    
    // 取り消し線が表示されることを確認
    expect(todoItem).toHaveStyle('text-decoration: line-through');
    
    // もう一度クリックして元に戻ることを確認
    fireEvent.click(checkbox);
    expect(todoItem).not.toHaveStyle('text-decoration: line-through');
  });
  
  test('タスクを削除できる', () => {
    render(<TodoList />);
    
    // タスクを追加
    const input = screen.getByTestId('todo-input');
    fireEvent.change(input, { target: { value: 'テストタスク' } });
    
    const addButton = screen.getByTestId('add-button');
    fireEvent.click(addButton);
    
    // タスクが表示されていることを確認
    expect(screen.getByText('テストタスク')).toBeInTheDocument();
    
    // 削除ボタンをクリック
    const deleteButton = screen.getByTestId(/^todo-delete-/);
    fireEvent.click(deleteButton);
    
    // タスクが削除されたことを確認
    expect(screen.queryByText('テストタスク')).not.toBeInTheDocument();
    
    // 空のメッセージが再表示されることを確認
    expect(screen.getByTestId('empty-message')).toBeInTheDocument();
  });
});

説明

Step 1Reactコンポーネントのテスト概要

Reactアプリケーションのテストは、コードの品質確保と安定性の向上に重要です。主に以下の種類のテストがあります:

  • ユニットテスト:個々のコンポーネントや関数の動作を検証
  • インテグレーションテスト:複数のコンポーネントの相互作用を検証
  • E2Eテスト:ユーザーの視点から実際のブラウザでアプリ全体の機能を検証

React用のテストツール:

  • Jest:Facebookが開発したJavaScriptテストフレームワーク
  • React Testing Library:ユーザー視点でのテストを推奨するライブラリ
  • Enzyme:Airbnbが開発したコンポーネントテスト用ライブラリ
  • Cypress:E2Eテスト用のフレームワーク

Step 2環境のセットアップ

Create React Appで作成したプロジェクトには、JestとReact Testing Libraryが既に含まれています。それ以外の場合は、以下のコマンドでインストールできます:

npm install --save-dev jest @testing-library/react @testing-library/jest-dom

package.jsonにテスト用のスクリプトを追加:

"scripts": { "test": "jest", "test:watch": "jest --watch" }

Step 3シンプルなコンポーネントのテスト

ボタンコンポーネントの例:

// Button.js import React from 'react'; function Button({ text, onClick }) { return ( <button onClick={onClick} className="custom-button" > {text} </button> ); } export default Button;

このコンポーネントのテスト:

// Button.test.js import React from 'react'; import { render, fireEvent, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import Button from './Button'; // ボタンが正しくレンダリングされるかテスト test('ボタンが正しくレンダリングされる', () => { render(<Button text="クリック" />); // screen.debug(); // コンポーネントのDOMを出力(デバッグ用) // ボタンがDOMに存在するか確認 const buttonElement = screen.getByText('クリック'); expect(buttonElement).toBeInTheDocument(); // ボタンが正しいクラスを持っているか確認 expect(buttonElement).toHaveClass('custom-button'); }); // クリックイベントが発火するかテスト test('クリックイベントが発火する', () => { // モック関数を作成 const handleClick = jest.fn(); // ボタンをレンダリング render(<Button text="クリック" onClick={handleClick} />); // ボタンをクリック const buttonElement = screen.getByText('クリック'); fireEvent.click(buttonElement); // クリックハンドラが1回呼び出されたことを確認 expect(handleClick).toHaveBeenCalledTimes(1); });

Step 4状態を持つコンポーネントのテスト

カウンターコンポーネントのテスト例:

// Counter.js import React, { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(prevCount => prevCount + 1); }; const decrement = () => { setCount(prevCount => prevCount - 1); }; return ( <div> <h2 data-testid="count">カウント: {count}</h2> <button onClick={increment}>増やす</button> <button onClick={decrement}>減らす</button> </div> ); } export default Counter;
// Counter.test.js import React from 'react'; import { render, fireEvent, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import Counter from './Counter'; test('カウンターが正しく動作する', () => { // カウンターコンポーネントをレンダリング render(<Counter />); // 初期値が0であることを確認 const countElement = screen.getByTestId('count'); expect(countElement).toHaveTextContent('カウント: 0'); // 「増やす」ボタンをクリック const incrementButton = screen.getByText('増やす'); fireEvent.click(incrementButton); // カウントが1になっていることを確認 expect(countElement).toHaveTextContent('カウント: 1'); // 「減らす」ボタンをクリック const decrementButton = screen.getByText('減らす'); fireEvent.click(decrementButton); // カウントが0に戻っていることを確認 expect(countElement).toHaveTextContent('カウント: 0'); });

Step 5非同期処理を含むコンポーネントのテスト

API呼び出しなどの非同期処理を含むコンポーネントのテスト例:

// UserList.js import React, { useState, useEffect } from 'react'; function UserList({ fetchUsers }) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const loadUsers = async () => { try { setLoading(true); const data = await fetchUsers(); setUsers(data); setError(null); } catch (err) { setError('ユーザーの取得に失敗しました'); setUsers([]); } finally { setLoading(false); } }; loadUsers(); }, [fetchUsers]); if (loading) return <div data-testid="loading">読み込み中...</div>; if (error) return <div data-testid="error">{error}</div>; return ( <div> <h2>ユーザー一覧</h2> <ul data-testid="user-list"> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> </div> ); }
// UserList.test.js import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import UserList from './UserList'; // 成功するAPIコールのモック const mockFetchUsersSuccess = jest.fn().mockResolvedValue([ { id: 1, name: 'テストユーザー1' }, { id: 2, name: 'テストユーザー2' } ]); // 失敗するAPIコールのモック const mockFetchUsersFailure = jest.fn().mockRejectedValue(new Error('API error')); test('ユーザー一覧が正しく表示される', async () => { // コンポーネントをレンダリング(成功するAPI関数を渡す) render(<UserList fetchUsers={mockFetchUsersSuccess} />); // 最初はローディング状態が表示されていることを確認 expect(screen.getByTestId('loading')).toBeInTheDocument(); // ユーザーリストが表示されるのを待つ await waitFor(() => { expect(screen.getByTestId('user-list')).toBeInTheDocument(); }); // ユーザーが正しく表示されていることを確認 expect(screen.getByText('テストユーザー1')).toBeInTheDocument(); expect(screen.getByText('テストユーザー2')).toBeInTheDocument(); // APIが1回呼び出されたことを確認 expect(mockFetchUsersSuccess).toHaveBeenCalledTimes(1); }); test('エラーメッセージが表示される', async () => { // コンポーネントをレンダリング(失敗するAPI関数を渡す) render(<UserList fetchUsers={mockFetchUsersFailure} />); // エラーメッセージが表示されるのを待つ await waitFor(() => { expect(screen.getByTestId('error')).toBeInTheDocument(); }); // エラーメッセージの内容を確認 expect(screen.getByTestId('error')).toHaveTextContent('ユーザーの取得に失敗しました'); });
テストのベストプラクティス:
  • 実装の詳細ではなく、ユーザーの行動に基づいてテストを書く
  • テストIDやアクセシブルな要素(ラベル、テキストなど)を使って要素を選択する
  • 各テストケースは独立しており、互いに影響を与えないようにする
  • DOMの変更を待つためにwaitForfindBy*クエリを使用する
  • 外部依存関係(API呼び出しなど)はモック化する
  • コンポーネントの主要な機能と境界条件をテストする
  • テストカバレッジを監視する(npm test -- --coverage

まとめ

  • React Testing Libraryはユーザー視点でコンポーネントをテストするライブラリ
  • render()でコンポーネントを描画し、screenオブジェクトで要素を取得する
  • fireEventuserEventでユーザー操作(クリック・入力)をシミュレートできる
  • expect().toBeInTheDocument()などのマッチャーで表示内容を検証する
  • 非同期処理のテストにはwaitForfindByクエリを使用する
  • テストはユーザーの操作フローに沿って記述すると、実際の動作に近い検証ができる