ユーザー操作はあらゆるReactアプリケーションの中核です。これらの操作を適切にテストすることで、コンポーネントがクリック、フォーム入力、キーボードイベントに正しく応答することを確認できます。
fireEvent vs userEvent
React Testing Libraryはユーザー操作をシミュレートする2つの方法を提供しています:
fireEvent
fireEventはDOMイベントを直接ディスパッチする低レベルユーティリティです:
import { render, screen, fireEvent } from '@testing-library/react';
test('fireEventでのボタンクリック', () => {
const handleClick = jest.fn();
render(<button onClick={handleClick}>Click me</button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
userEvent
userEventは実際のユーザー動作をより正確にシミュレートします:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('userEventでのボタンクリック', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<button onClick={handleClick}>Click me</button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
主な違い
flowchart LR
subgraph fireEvent["fireEvent"]
A[単一イベントをディスパッチ]
B[同期処理]
C[低レベルAPI]
end
subgraph userEvent["userEvent"]
D[完全な操作をシミュレート]
E[非同期処理]
F[リアルな動作]
end
style fireEvent fill:#f59e0b,color:#fff
style userEvent fill:#10b981,color:#fff
| 項目 | fireEvent | userEvent |
|---|---|---|
| クリック | 単一のクリックイベント | focus → mousedown → mouseup → click |
| 入力 | 単一のchangeイベント | focus → keydown → keypress → input → keyup(文字ごと) |
| API | 同期 | 非同期(Promiseを返す) |
| リアルさ | 低い | 高い |
推奨: ほとんどのテストではuserEventを使用してください。実際のユーザー動作をシミュレートするため、より多くのバグを発見できます。
userEventのセットアップ
常にsetup()でuserインスタンスを作成します:
import userEvent from '@testing-library/user-event';
test('例', async () => {
// レンダリング前にuserインスタンスを作成
const user = userEvent.setup();
render(<MyComponent />);
// userインスタンスを使って操作
await user.click(element);
});
セットアップオプション
const user = userEvent.setup({
// キー入力間の遅延(ミリ秒)
delay: 50,
// ポインターイベントの検証をスキップ
pointerEventsCheck: 0,
// カスタムドキュメント
document: customDocument,
});
クリック操作
基本的なクリック
test('ボタンクリックを処理', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>送信</Button>);
await user.click(screen.getByRole('button', { name: '送信' }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
ダブルクリック
test('ダブルクリックを処理', async () => {
const user = userEvent.setup();
const handleDoubleClick = jest.fn();
render(<div onDoubleClick={handleDoubleClick}>ダブルクリックしてね</div>);
await user.dblClick(screen.getByText('ダブルクリックしてね'));
expect(handleDoubleClick).toHaveBeenCalledTimes(1);
});
右クリック(コンテキストメニュー)
test('右クリックでコンテキストメニューを開く', async () => {
const user = userEvent.setup();
const handleContextMenu = jest.fn();
render(<div onContextMenu={handleContextMenu}>右クリックしてね</div>);
await user.pointer({
keys: '[MouseRight]',
target: screen.getByText('右クリックしてね'),
});
expect(handleContextMenu).toHaveBeenCalled();
});
修飾キー付きクリック
test('Ctrl+クリックを処理', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<button onClick={handleClick}>クリック</button>);
await user.click(screen.getByRole('button'), {
ctrlKey: true,
});
expect(handleClick).toHaveBeenCalledWith(
expect.objectContaining({ ctrlKey: true })
);
});
フォーム入力のテスト
テキスト入力
test('テキスト入力を更新', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
render(<input onChange={handleChange} placeholder="名前を入力" />);
const input = screen.getByPlaceholderText('名前を入力');
await user.type(input, 'John Doe');
expect(input).toHaveValue('John Doe');
// 文字ごとに1回呼ばれる
expect(handleChange).toHaveBeenCalledTimes(8);
});
クリアして入力
test('クリアして新しい値を入力', async () => {
const user = userEvent.setup();
render(<input defaultValue="古い値" />);
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, '新しい値');
expect(input).toHaveValue('新しい値');
});
特殊文字
test('特殊文字を入力', async () => {
const user = userEvent.setup();
render(<input />);
const input = screen.getByRole('textbox');
// {selectall} - すべて選択
// {backspace} - 選択を削除
// {enter} - Enterキー
await user.type(input, 'Hello{selectall}{backspace}World{enter}');
expect(input).toHaveValue('World');
});
チェックボックスとラジオボタン
test('チェックボックスを切り替え', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
render(
<label>
<input type="checkbox" onChange={handleChange} />
利用規約に同意
</label>
);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
test('ラジオオプションを選択', async () => {
const user = userEvent.setup();
render(
<fieldset>
<label><input type="radio" name="size" value="small" /> 小</label>
<label><input type="radio" name="size" value="large" /> 大</label>
</fieldset>
);
await user.click(screen.getByLabelText('大'));
expect(screen.getByLabelText('大')).toBeChecked();
expect(screen.getByLabelText('小')).not.toBeChecked();
});
セレクトドロップダウン
test('ドロップダウンからオプションを選択', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
render(
<select onChange={handleChange}>
<option value="">色を選択</option>
<option value="red">赤</option>
<option value="blue">青</option>
</select>
);
await user.selectOptions(screen.getByRole('combobox'), 'blue');
expect(screen.getByRole('combobox')).toHaveValue('blue');
expect(handleChange).toHaveBeenCalled();
});
test('複数のオプションを選択', async () => {
const user = userEvent.setup();
render(
<select multiple>
<option value="apple">りんご</option>
<option value="banana">バナナ</option>
<option value="cherry">さくらんぼ</option>
</select>
);
await user.selectOptions(screen.getByRole('listbox'), ['apple', 'cherry']);
expect(screen.getByRole('option', { name: 'りんご' })).toBeSelected();
expect(screen.getByRole('option', { name: 'バナナ' })).not.toBeSelected();
expect(screen.getByRole('option', { name: 'さくらんぼ' })).toBeSelected();
});
フォーム送信
フォーム送信のテスト
test('ユーザーデータでフォームを送信', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(
<form onSubmit={handleSubmit}>
<label htmlFor="email">メール</label>
<input id="email" name="email" type="email" />
<label htmlFor="password">パスワード</label>
<input id="password" name="password" type="password" />
<button type="submit">ログイン</button>
</form>
);
await user.type(screen.getByLabelText('メール'), 'test@example.com');
await user.type(screen.getByLabelText('パスワード'), 'secret123');
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(handleSubmit).toHaveBeenCalledTimes(1);
});
フォームバリデーションのテスト
function LoginForm({ onSubmit }) {
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
const form = e.target;
const email = form.email.value;
const password = form.password.value;
const newErrors = {};
if (!email) newErrors.email = 'メールは必須です';
if (!password) newErrors.password = 'パスワードは必須です';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">メール</label>
<input id="email" name="email" />
{errors.email && <span role="alert">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">パスワード</label>
<input id="password" name="password" type="password" />
{errors.password && <span role="alert">{errors.password}</span>}
</div>
<button type="submit">ログイン</button>
</form>
);
}
test('バリデーションエラーを表示', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// フォームを入力せずに送信
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(screen.getByText('メールは必須です')).toBeInTheDocument();
expect(screen.getByText('パスワードは必須です')).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
test('バリデーションが通ると送信', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('メール'), 'test@example.com');
await user.type(screen.getByLabelText('パスワード'), 'password123');
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
キーボードイベント
基本的なキーボード入力
test('キーボードショートカットを処理', async () => {
const user = userEvent.setup();
const handleKeyDown = jest.fn();
render(<input onKeyDown={handleKeyDown} />);
const input = screen.getByRole('textbox');
await user.type(input, '{Control>}s{/Control}');
expect(handleKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
key: 's',
ctrlKey: true,
})
);
});
Tabナビゲーション
test('Tabキーでナビゲート', async () => {
const user = userEvent.setup();
render(
<form>
<input placeholder="最初" />
<input placeholder="2番目" />
<button>送信</button>
</form>
);
// 要素間をTabで移動
await user.tab();
expect(screen.getByPlaceholderText('最初')).toHaveFocus();
await user.tab();
expect(screen.getByPlaceholderText('2番目')).toHaveFocus();
await user.tab();
expect(screen.getByRole('button')).toHaveFocus();
// Shift+Tabで戻る
await user.tab({ shift: true });
expect(screen.getByPlaceholderText('2番目')).toHaveFocus();
});
Enterキーでの送信
test('Enterキーで送信', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(
<form onSubmit={handleSubmit}>
<input placeholder="検索" />
<button type="submit">検索</button>
</form>
);
const input = screen.getByPlaceholderText('検索');
await user.type(input, 'react testing{enter}');
expect(handleSubmit).toHaveBeenCalled();
});
Escapeキー
test('Escapeでモーダルを閉じる', async () => {
const user = userEvent.setup();
const handleClose = jest.fn();
render(
<div role="dialog" onKeyDown={(e) => e.key === 'Escape' && handleClose()}>
<p>モーダルの内容</p>
</div>
);
await user.keyboard('{Escape}');
expect(handleClose).toHaveBeenCalled();
});
ホバーとフォーカス
ホバーイベント
test('ホバーでツールチップを表示', async () => {
const user = userEvent.setup();
render(
<Tooltip text="詳細情報">
<button>ホバーしてね</button>
</Tooltip>
);
// 最初はツールチップは見えない
expect(screen.queryByText('詳細情報')).not.toBeInTheDocument();
// ボタンにホバー
await user.hover(screen.getByRole('button'));
expect(screen.getByText('詳細情報')).toBeInTheDocument();
// 離れる
await user.unhover(screen.getByRole('button'));
expect(screen.queryByText('詳細情報')).not.toBeInTheDocument();
});
フォーカスイベント
test('フォーカスインジケーターを表示', async () => {
const user = userEvent.setup();
const handleFocus = jest.fn();
const handleBlur = jest.fn();
render(
<input
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="フォーカスしてね"
/>
);
const input = screen.getByPlaceholderText('フォーカスしてね');
// Tabでフォーカス
await user.tab();
expect(handleFocus).toHaveBeenCalled();
expect(input).toHaveFocus();
// Tabで離れる
await user.tab();
expect(handleBlur).toHaveBeenCalled();
expect(input).not.toHaveFocus();
});
よくあるパターン
制御コンポーネントのテスト
function ControlledInput({ value, onChange }) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
test('制御された入力が親の状態を更新', async () => {
const user = userEvent.setup();
let value = '';
const setValue = jest.fn((v) => { value = v; });
const { rerender } = render(
<ControlledInput value={value} onChange={setValue} />
);
const input = screen.getByRole('textbox');
await user.type(input, 'a');
expect(setValue).toHaveBeenCalledWith('a');
// 新しい値で再レンダリングして親の更新をシミュレート
rerender(<ControlledInput value="a" onChange={setValue} />);
expect(input).toHaveValue('a');
});
デバウンス入力のテスト
test('デバウンスされた検索入力', async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const handleSearch = jest.fn();
render(<SearchInput onSearch={handleSearch} debounceMs={300} />);
await user.type(screen.getByRole('searchbox'), 'react');
// まだ呼ばれない(デバウンス中)
expect(handleSearch).not.toHaveBeenCalled();
// タイマーを進める
jest.advanceTimersByTime(300);
expect(handleSearch).toHaveBeenCalledWith('react');
jest.useRealTimers();
});
まとめ
| メソッド | ユースケース |
|---|---|
user.click() |
ボタンクリック、チェックボックス、リンク |
user.dblClick() |
ダブルクリック操作 |
user.type() |
リアルなキー入力でテキスト入力 |
user.clear() |
入力フィールドをクリア |
user.selectOptions() |
ドロップダウンオプションを選択 |
user.tab() |
キーボードナビゲーション |
user.keyboard() |
複雑なキーボードシーケンス |
user.hover() / user.unhover() |
マウスホバーイベント |
重要なポイント:
- リアルな操作のために
fireEventよりuserEventを優先 - レンダリング前に常に
userEvent.setup()でuserインスタンスを作成 - すべての
userEventメソッドでawaitを使用(非同期) - 無効/有効なデータで送信してフォームバリデーションをテスト
user.tab()でキーボードナビゲーションとアクセシビリティをテストtype()の特殊文字は波括弧で囲む:{enter}、{backspace}
ユーザー操作を適切にテストすることで、コンポーネントがユーザーの期待通りに動作することを確認できます。userEventは実際のブラウザ動作をシミュレートすることで、より多くのバグを発見できます。
参考文献
- Testing Library User Event Documentation
- Testing Library fireEvent Documentation
- Crump, Scottie. Simplify Testing with React Testing Library. Packt, 2021.
- Barklund, Morten. React in Depth. Manning Publications, 2024.