Day 6: Reactコンポーネントのテスト
今日学ぶこと
- React Testing Library とは何か、なぜ使うのか
render、screen、fireEventの基本的な使い方userEventによるよりリアルな操作シミュレーションgetBy/queryBy/findByクエリの使い分け- フォームのテスト方法
- カスタムフックのテスト(
renderHook) - 実践例:Todoリストコンポーネントのテスト
React Testing Library とは
React Testing Library(RTL)は、Reactコンポーネントをユーザーの視点からテストするためのライブラリです。「10日で覚えるReact」で学んだコンポーネントの知識を活かして、それらが正しく動作するかを検証していきましょう。
従来のテスト手法では、コンポーネントの内部実装(state や props の値)を直接検証していました。RTL は「ユーザーがどう操作し、何が表示されるか」に焦点を当てます。
flowchart LR
subgraph Old["従来のテスト手法"]
IMPL["内部実装をテスト"]
STATE["stateの値を検証"]
PROPS["propsの受け渡しを検証"]
end
subgraph RTL["React Testing Library"]
USER["ユーザー視点でテスト"]
RENDER["画面に何が表示されるか"]
INTERACT["操作したら何が起きるか"]
end
IMPL --> STATE
IMPL --> PROPS
USER --> RENDER
USER --> INTERACT
style Old fill:#ef4444,color:#fff
style RTL fill:#22c55e,color:#fff
セットアップ
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
テストファイルの冒頭で @testing-library/jest-dom をインポートすると、DOM 専用のマッチャー(toBeInTheDocument() など)が使えるようになります。
import '@testing-library/jest-dom';
render, screen, fireEvent の基本
RTL の3つの基本 API を見ていきましょう。
| API | 役割 |
|---|---|
render() |
コンポーネントを仮想DOMにレンダリングする |
screen |
レンダリングされたDOMを検索するユーティリティ |
fireEvent |
DOM イベントを発火させる |
はじめてのコンポーネントテスト
まずはシンプルなコンポーネントをテストしてみましょう。
// Greeting.jsx
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Greeting from './Greeting';
test('renders greeting with the given name', () => {
render(<Greeting name="Jest" />);
// Find heading by its text content
const heading = screen.getByText('Hello, Jest!');
expect(heading).toBeInTheDocument();
});
TypeScript版:
// Greeting.tsx
type GreetingProps = {
name: string;
};
function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
// Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Greeting from './Greeting';
test('renders greeting with the given name', () => {
render(<Greeting name="Jest" />);
const heading = screen.getByText('Hello, Jest!');
expect(heading).toBeInTheDocument();
});
fireEvent でクリックをテスト
// Counter.jsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
export default Counter;
// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Counter from './Counter';
test('increments count when button is clicked', () => {
render(<Counter />);
// Initial state
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Click the button
fireEvent.click(screen.getByText('Increment'));
// After click
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
TypeScript版:
// Counter.tsx
import { useState } from 'react';
function Counter(): JSX.Element {
const [count, setCount] = useState<number>(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
export default Counter;
userEvent による操作のシミュレーション
fireEvent は低レベルな DOM イベントを直接発火しますが、userEvent はユーザーの実際の操作をより忠実に再現します。
flowchart TB
subgraph FE["fireEvent"]
FE1["click → clickイベントのみ"]
FE2["change → changeイベントのみ"]
end
subgraph UE["userEvent"]
UE1["click → pointerdown → mousedown → focus → pointerup → mouseup → click"]
UE2["type → focus → keydown → keypress → input → keyup(1文字ごと)"]
end
style FE fill:#f59e0b,color:#fff
style UE fill:#22c55e,color:#fff
| 比較項目 | fireEvent |
userEvent |
|---|---|---|
| イベントの再現度 | 単一イベントのみ | 実際のブラウザ動作を再現 |
| フォーカス管理 | 手動で管理 | 自動的に処理 |
| テキスト入力 | change で一括 |
1文字ずつ入力 |
| 推奨度 | 特殊なケースに | 基本的にこちらを使う |
userEvent の使い方
userEvent v14 以降は setup() を使ってインスタンスを作成します。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('increments count with userEvent', async () => {
const user = userEvent.setup();
render(<Counter />);
// userEvent methods are async
await user.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
主な userEvent メソッド
| メソッド | 説明 | 例 |
|---|---|---|
user.click(element) |
クリック | ボタン押下 |
user.dblClick(element) |
ダブルクリック | 項目選択 |
user.type(element, text) |
テキスト入力 | フォーム入力 |
user.clear(element) |
入力クリア | フォームリセット |
user.selectOptions(element, values) |
セレクトボックス選択 | ドロップダウン |
user.tab() |
Tabキー | フォーカス移動 |
user.keyboard(text) |
キーボード入力 | ショートカットキー |
getBy / queryBy / findBy の使い分け
RTL には3種類のクエリがあり、それぞれ挙動が異なります。これはテストを書く上で非常に重要な使い分けです。
flowchart TB
Q["要素を検索したい"]
Q --> EXISTS{"要素は存在する?"}
EXISTS -->|"必ず存在する"| GETBY["getBy を使う"]
EXISTS -->|"存在しないことを確認したい"| QUERYBY["queryBy を使う"]
EXISTS -->|"非同期で表示される"| FINDBY["findBy を使う"]
GETBY -->|"見つからない"| ERROR1["エラーをスロー"]
QUERYBY -->|"見つからない"| NULL["null を返す"]
FINDBY -->|"タイムアウト"| ERROR2["エラーをスロー"]
style GETBY fill:#3b82f6,color:#fff
style QUERYBY fill:#8b5cf6,color:#fff
style FINDBY fill:#22c55e,color:#fff
| クエリ | 要素がない場合 | 非同期対応 | 主な用途 |
|---|---|---|---|
getBy* |
エラーをスロー | ✗ | 要素が存在することを前提としたテスト |
queryBy* |
null を返す |
✗ | 要素が存在しないことを確認 |
findBy* |
エラーをスロー | ✓ (Promise) | 非同期で表示される要素を待つ |
使い分けの実例
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
function Toggle() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(!show)}>Toggle</button>
{show && <p>Secret content</p>}
</div>
);
}
test('toggles content visibility', async () => {
const user = userEvent.setup();
render(<Toggle />);
// queryBy: confirm element does NOT exist
expect(screen.queryByText('Secret content')).not.toBeInTheDocument();
await user.click(screen.getByText('Toggle'));
// getBy: confirm element exists
expect(screen.getByText('Secret content')).toBeInTheDocument();
});
findBy — 非同期表示の待機
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
if (!user) return <p>Loading...</p>;
return <p>{user.name}</p>;
}
test('displays user name after loading', async () => {
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'Alice' }),
})
);
render(<UserProfile userId={1} />);
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// findBy waits for the element to appear (default timeout: 1000ms)
const userName = await screen.findByText('Alice');
expect(userName).toBeInTheDocument();
});
クエリのサフィックス一覧
要素を検索するサフィックスも複数あります。アクセシビリティの観点から、上にあるものほど優先的に使いましょう。
| サフィックス | 説明 | 優先度 |
|---|---|---|
ByRole |
ARIAロールで検索 | ★★★ 最優先 |
ByLabelText |
ラベルテキストで検索 | ★★★ |
ByPlaceholderText |
プレースホルダーで検索 | ★★☆ |
ByText |
テキストコンテンツで検索 | ★★☆ |
ByDisplayValue |
入力値で検索 | ★★☆ |
ByAltText |
alt属性で検索 | ★☆☆ |
ByTitle |
title属性で検索 | ★☆☆ |
ByTestId |
data-testidで検索 | ★☆☆ 最後の手段 |
// Prefer ByRole over ByText
screen.getByRole('button', { name: 'Submit' }); // Good
screen.getByText('Submit'); // OK but less specific
// Use ByLabelText for form elements
screen.getByLabelText('Email'); // Good
screen.getByPlaceholderText('Enter email'); // OK
// Use ByTestId only as a last resort
screen.getByTestId('custom-element'); // Last resort
フォームのテスト
フォームのテストは React コンポーネントテストの中でも特に重要です。「10日で覚えるReact」で学んだ制御コンポーネントの知識が役立ちます。
基本的なフォームテスト
// LoginForm.jsx
import { useState } from 'react';
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
);
}
export default LoginForm;
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits form with email and password', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Fill in the form
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
// Submit the form
await user.click(screen.getByRole('button', { name: 'Login' }));
// Verify submission
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
test('does not submit when fields are empty', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Verify initial state
expect(screen.getByLabelText('Email')).toHaveValue('');
expect(screen.getByLabelText('Password')).toHaveValue('');
});
TypeScript版:
// LoginForm.tsx
import { useState, FormEvent } from 'react';
type LoginData = {
email: string;
password: string;
};
type LoginFormProps = {
onSubmit: (data: LoginData) => void;
};
function LoginForm({ onSubmit }: LoginFormProps): JSX.Element {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
);
}
export default LoginForm;
バリデーションのテスト
// ValidationForm.jsx
import { useState } from 'react';
function ValidationForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email.includes('@')) {
setError('Please enter a valid email address');
return;
}
setError('');
onSubmit(email);
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Submit</button>
</form>
);
}
// ValidationForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ValidationForm from './ValidationForm';
test('shows error for invalid email', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<ValidationForm onSubmit={handleSubmit} />);
// Enter invalid email
await user.type(screen.getByLabelText('Email'), 'invalid-email');
await user.click(screen.getByRole('button', { name: 'Submit' }));
// Error message should appear
expect(screen.getByRole('alert')).toHaveTextContent(
'Please enter a valid email address'
);
// onSubmit should NOT be called
expect(handleSubmit).not.toHaveBeenCalled();
});
test('submits valid email without error', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<ValidationForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
// No error should be displayed
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// onSubmit should be called
expect(handleSubmit).toHaveBeenCalledWith('user@example.com');
});
カスタムフックのテスト(renderHook)
「10日で覚えるReact」で学んだカスタムフックは、renderHook を使ってテストできます。コンポーネントを用意せずにフックの動作を直接テストできるため非常に便利です。
// useCounter.js
import { useState, useCallback } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
export default useCounter;
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
// State updates must be wrapped in act()
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets counter to initial value', () => {
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);
});
TypeScript版:
// useCounter.ts
import { useState, useCallback } from 'react';
type UseCounterReturn = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
function useCounter(initialValue: number = 0): UseCounterReturn {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
export default useCounter;
非同期カスタムフックのテスト
// useFetch.js
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(json => {
if (!cancelled) {
setData(json);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
export default useFetch;
// useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react';
import useFetch from './useFetch';
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Alice' };
global.fetch.mockResolvedValue({
json: () => Promise.resolve(mockData),
});
const { result } = renderHook(() => useFetch('/api/users/1'));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
// Wait for fetch to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
test('handles fetch error', async () => {
global.fetch.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useFetch('/api/users/1'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Network error');
expect(result.current.data).toBeNull();
});
実践例:Todoリストコンポーネントのテスト
ここまでの知識を統合して、実践的な Todo リストコンポーネントのテストを書いてみましょう。
コンポーネントの実装
// TodoList.jsx
import { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
if (input.trim() === '') return;
setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
setInput('');
};
const toggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<h1>Todo List</h1>
<div>
<label htmlFor="new-todo">New Todo</label>
<input
id="new-todo"
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Enter a new todo"
/>
<button onClick={addTodo}>Add</button>
</div>
{todos.length === 0 ? (
<p>No todos yet. Add one above!</p>
) : (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.text}
</span>
</label>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
)}
{todos.length > 0 && (
<p>
{todos.filter(t => t.completed).length} / {todos.length} completed
</p>
)}
</div>
);
}
export default TodoList;
テストの実装
// TodoList.test.jsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import TodoList from './TodoList';
describe('TodoList', () => {
let user;
beforeEach(() => {
user = userEvent.setup();
});
// --- Rendering ---
test('renders the title', () => {
render(<TodoList />);
expect(screen.getByText('Todo List')).toBeInTheDocument();
});
test('shows empty state message when no todos', () => {
render(<TodoList />);
expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
});
// --- Adding Todos ---
test('adds a new todo', async () => {
render(<TodoList />);
await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('Buy milk')).toBeInTheDocument();
// Empty state should disappear
expect(
screen.queryByText('No todos yet. Add one above!')
).not.toBeInTheDocument();
});
test('clears input after adding a todo', async () => {
render(<TodoList />);
const input = screen.getByLabelText('New Todo');
await user.type(input, 'Buy milk');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(input).toHaveValue('');
});
test('does not add empty todo', async () => {
render(<TodoList />);
// Try to add without typing anything
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
});
test('does not add whitespace-only todo', async () => {
render(<TodoList />);
await user.type(screen.getByLabelText('New Todo'), ' ');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
});
// --- Toggling Todos ---
test('toggles todo completion', async () => {
render(<TodoList />);
// Add a todo
await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
await user.click(screen.getByRole('button', { name: 'Add' }));
// Toggle completion
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
// Toggle back
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
// --- Deleting Todos ---
test('deletes a todo', async () => {
render(<TodoList />);
// Add a todo
await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('Buy milk')).toBeInTheDocument();
// Delete it
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
// Empty state should reappear
expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
});
// --- Completion Counter ---
test('shows completion counter', async () => {
render(<TodoList />);
// Add two todos
await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
await user.click(screen.getByRole('button', { name: 'Add' }));
await user.type(screen.getByLabelText('New Todo'), 'Walk the dog');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('0 / 2 completed')).toBeInTheDocument();
// Complete one todo
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]);
expect(screen.getByText('1 / 2 completed')).toBeInTheDocument();
});
// --- Multiple Todos ---
test('manages multiple todos independently', async () => {
render(<TodoList />);
// Add three todos
const todosToAdd = ['Buy milk', 'Walk the dog', 'Read a book'];
for (const todo of todosToAdd) {
await user.type(screen.getByLabelText('New Todo'), todo);
await user.click(screen.getByRole('button', { name: 'Add' }));
}
// All three should be visible
todosToAdd.forEach(todo => {
expect(screen.getByText(todo)).toBeInTheDocument();
});
// Delete the middle one
const listItems = screen.getAllByRole('listitem');
const middleItem = listItems[1];
await user.click(within(middleItem).getByRole('button', { name: 'Delete' }));
// Only two should remain
expect(screen.getByText('Buy milk')).toBeInTheDocument();
expect(screen.queryByText('Walk the dog')).not.toBeInTheDocument();
expect(screen.getByText('Read a book')).toBeInTheDocument();
});
});
TypeScript版:
// TodoList.tsx
import { useState } from 'react';
type Todo = {
id: number;
text: string;
completed: boolean;
};
function TodoList(): JSX.Element {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState('');
const addTodo = (): void => {
if (input.trim() === '') return;
setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
setInput('');
};
const toggleTodo = (id: number): void => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id: number): void => {
setTodos(todos.filter(todo => todo.id !== id));
};
// ... JSX is the same as the JavaScript version
}
まとめ
今日はReact Testing Libraryを使ったコンポーネントテストの基本を学びました。
| トピック | ポイント |
|---|---|
| React Testing Library | ユーザー視点でテストする。内部実装に依存しない |
render / screen |
コンポーネントをレンダリングし、DOMを検索する |
fireEvent vs userEvent |
userEvent の方がリアルな操作を再現できる |
getBy / queryBy / findBy |
要素の存在/不在/非同期表示に応じて使い分ける |
| クエリの優先順位 | ByRole > ByLabelText > ByText > ByTestId |
| フォームテスト | getByLabelText でフォーム要素を取得し、userEvent.type で入力 |
renderHook |
カスタムフックを単独でテストできる |
重要な原則:
- ユーザーがどう操作するかを基準にテストを書く
- 内部の state や props を直接テストしない
- アクセシビリティに配慮したクエリ(
ByRole、ByLabelText)を優先する userEventをfireEventより優先する
練習問題
問題1: 基本
以下の Alert コンポーネントのテストを書いてください。表示/非表示の切り替えと、閉じるボタンの動作を検証しましょう。
function Alert({ message, onClose }) {
return (
<div role="alert">
<p>{message}</p>
<button onClick={onClose}>Close</button>
</div>
);
}
問題2: 応用
以下の SearchBox コンポーネントのテストを書いてください。テキスト入力と検索結果のフィルタリングを検証しましょう。
function SearchBox({ items }) {
const [query, setQuery] = useState('');
const filtered = items.filter(item =>
item.toLowerCase().includes(query.toLowerCase())
);
return (
<div>
<label htmlFor="search">Search</label>
<input
id="search"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{filtered.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
<p>{filtered.length} results found</p>
</div>
);
}
チャレンジ問題
以下の useLocalStorage カスタムフックのテストを renderHook を使って書いてください。初期値の取得、値の更新、localStorage との同期を検証しましょう。
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
参考リンク
- Testing Library - Introduction
- Testing Library - React
- Testing Library - Queries
- Testing Library - User Event
- Testing Library - renderHook
- Jest DOM Matchers
次回予告: Day 7では「スナップショットテスト」について学びます。コンポーネントのUI変更を自動的に検出する仕組みと、toMatchSnapshot() / toMatchInlineSnapshot() の使い方を見ていきましょう!