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の変更を待つために
waitForやfindBy*クエリを使用する - 外部依存関係(API呼び出しなど)はモック化する
- コンポーネントの主要な機能と境界条件をテストする
- テストカバレッジを監視する(
npm test -- --coverage)
まとめ
- React Testing Libraryはユーザー視点でコンポーネントをテストするライブラリ
render()でコンポーネントを描画し、screenオブジェクトで要素を取得するfireEventやuserEventでユーザー操作(クリック・入力)をシミュレートできるexpect().toBeInTheDocument()などのマッチャーで表示内容を検証する- 非同期処理のテストには
waitForやfindByクエリを使用する - テストはユーザーの操作フローに沿って記述すると、実際の動作に近い検証ができる