テスト講座 #5 ユーザーイベントとフォームテスト — userEvent の出番
#3 でクリックを一度ずつ押さえ、#4 でデータフェッチを学びました。今回のテーマは ユーザー入力。フォームはいつもテストが面倒に感じる領域ですが、実はパターンは2、3個に絞られます。
テスト講座 におけるこの記事の位置づけ。
- #1 なぜテストか
- #2 Vitest セットアップと最初のユニットテスト
- #3 React Testing Library
- #4 非同期とネットワークモック — MSW
- #5 ユーザーイベントとフォームテスト — userEvent の出番 ← この記事
- #6 Playwright で E2E と CI 統合
この記事は userEvent の深掘り + フォームライブラリ上でのテストパターン。
userEvent vs fireEvent — 本当の違い
#
#3 で押さえたところをもう一段深く。
fireEvent は単一の DOM イベントを発火します。fireEvent.click(el) は「click イベント1個」を dispatch するだけ。ユーザーが実際にクリックしたときに起こる他のこと — focus の変化、mousedown/mouseup、hover — は全部抜け落ちます。
userEvent はその場のシーケンスを全部シミュレーションします。1回のクリックは実際には focus → mousedown → mouseup → click の順序で、userEvent はそのすべてを発火します。
사용자 클릭
│
▼
focus 이벤트 (만약 elememnt 가 focusable)
│
▼
mousedown
│
▼
mouseup
│
▼
click
# fireEvent.click 은 마지막 click 만
# userEvent.click 은 4 단계 모두違いが現れるケース。
function Tooltip() {
const [open, setOpen] = useState(false);
return (
<button
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
hover for tip
{open && <span role="tooltip">helpful info</span>}
</button>
);
}// X — focus 가 안 일어나서 tooltip 이 안 뜸
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
// O — focus 가 일어나서 tooltip 이 뜸
const user = userEvent.setup();
await user.click(screen.getByRole('button'));
expect(screen.getByRole('tooltip')).toBeInTheDocument();このようなケースは1つや2つではありません。入力マスキング、オートコンプリート、hover プレビュー、focus トラップ — どれも単一イベントでは捕まえられない流れです。新規に書くテストはほぼ常に userEvent が答えです。
userEvent.setup() — 1テストに1インスタンス
#
毎テストで setup を呼びます。
it('어떤 동작', async () => {
const user = userEvent.setup();
render(<MyComponent />);
await user.click(...);
});setup() が作る user インスタンスは、internal pointer の位置やキーボード modifier の状態(Shift、Cmd を押しているか)を保持しています。前のテストの状態が漏れないよう、毎テストで新しいインスタンスを作る必要があります。
複数のテストが同じ setup を共有するなら、helper にまとめることもできます。
function setup(jsx: React.ReactElement) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
it('...', async () => {
const { user } = setup(<MyComponent />);
await user.click(...);
});よく使うメソッド #
userEvent の主要メソッドを一箇所に。
// 클릭
await user.click(el);
await user.dblClick(el);
await user.tripleClick(el);
// 입력
await user.type(input, 'hello'); // 한 글자씩 type
await user.type(input, 'hello{Enter}'); // 특수 키 포함
await user.clear(input); // 비우기
await user.paste('text'); // 붙여넣기 (선택된 input 에)
// 선택
await user.selectOptions(select, 'value'); // <select>
await user.selectOptions(select, ['a', 'b']); // multi select
// 키보드
await user.keyboard('{Enter}');
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
await user.keyboard('{Control>}c{/Control}'); // Ctrl+C
// hover
await user.hover(el);
await user.unhover(el);
// 탭 (focus 이동)
await user.tab();
await user.tab({ shift: true }); // Shift+Tab
// 파일 업로드
await user.upload(input, new File(['content'], 'test.txt'));
// 여러 파일
await user.upload(input, [file1, file2]);type の中の特殊キーは波カッコ表記。
{Enter} {Tab} {Escape} {Backspace}
{Delete} {Space} {ArrowLeft} {ArrowRight}
{ArrowUp} {ArrowDown}LoginForm — フォーム全体のテスト #
#1 で少し登場した LoginForm を本格的にテストしてみます。
import { useState, FormEvent } from 'react';
type Props = {
onSubmit: (values: { email: string; password: string }) => void;
};
export function LoginForm({ onSubmit }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!email.includes('@')) {
setError('올바른 이메일을 입력하세요');
return;
}
if (password.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다');
return;
}
setError(null);
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit} aria-label="login">
<label>
이메일
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label>
비밀번호
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit">로그인</button>
</form>
);
}テスト — シナリオごとに。
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
// ── 1. happy path ──────────────────────────────
it('유효한 값을 채워 제출하면 onSubmit 이 그 값과 호출된다', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'a@b.com');
await user.type(screen.getByLabelText('비밀번호'), 'secret123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'a@b.com',
password: 'secret123',
});
});
// ── 2. 이메일 검증 ─────────────────────────────
it('이메일에 @ 가 없으면 에러 메시지를 띄우고 제출하지 않는다', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'invalid');
await user.type(screen.getByLabelText('비밀번호'), 'secret123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.getByRole('alert')).toHaveTextContent('올바른 이메일');
expect(onSubmit).not.toHaveBeenCalled();
});
// ── 3. 비밀번호 검증 ───────────────────────────
it('비밀번호가 8자 미만이면 에러를 띄운다', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'a@b.com');
await user.type(screen.getByLabelText('비밀번호'), 'short');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.getByRole('alert')).toHaveTextContent('8자 이상');
expect(onSubmit).not.toHaveBeenCalled();
});
// ── 4. 에러가 다음 제출에서 사라지는지 ───────────
it('에러 후 올바른 값으로 다시 제출하면 에러가 사라지고 통과한다', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
// 처음엔 잘못된 입력
await user.type(screen.getByLabelText('이메일'), 'invalid');
await user.type(screen.getByLabelText('비밀번호'), 'secret123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.getByRole('alert')).toBeInTheDocument();
// 이메일을 고쳐서 다시
await user.clear(screen.getByLabelText('이메일'));
await user.type(screen.getByLabelText('이메일'), 'a@b.com');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});このパターンが、ほぼすべてのフォームテストの骨格です。
- happy path 1個
- 検証ルールごとに1個ずつ(メール、パスワード…)
- エラー → 復帰のシナリオ1個
これくらいでフォームの動作は十分に押さえられます。それ以上は普通やりすぎです。
Enter キーで送信 — キーボードの流れ
#
キーボードだけでフォームを埋めるユーザーも検証する必要があるかもしれません。
it('Enter 키로 폼을 제출할 수 있다', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'a@b.com');
await user.tab(); // 비밀번호 input 으로 focus 이동
await user.keyboard('secret123{Enter}');
expect(onSubmit).toHaveBeenCalled();
});input の中で {Enter} を押すと、フォームの onSubmit が自然に発火します — ブラウザのデフォルト動作です。特別な処理なしで動きます。
React Hook Form の上でのテスト #
実戦ではフォームライブラリ(React Hook Form、Formik)をよく使います。テストの流れはほぼ同じですが、1、2箇所だけ違います。
import { useForm } from 'react-hook-form';
type FormValues = { email: string; password: string };
export function LoginForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>();
return (
<form onSubmit={handleSubmit(onSubmit)} aria-label="login">
<label>
이메일
<input
{...register('email', {
required: '이메일을 입력하세요',
pattern: { value: /@/, message: '올바른 이메일을 입력하세요' },
})}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
</label>
{errors.email && (
<p id="email-error" role="alert">{errors.email.message}</p>
)}
<label>
비밀번호
<input
type="password"
{...register('password', {
required: '비밀번호를 입력하세요',
minLength: { value: 8, message: '8자 이상' },
})}
aria-invalid={errors.password ? 'true' : 'false'}
/>
</label>
{errors.password && <p role="alert">{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>로그인</button>
</form>
);
}テストはほぼ同じです。
it('RHF 폼 — 유효한 값으로 제출', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'a@b.com');
await user.type(screen.getByLabelText('비밀번호'), 'secret123');
await user.click(screen.getByRole('button', { name: '로그인' }));
// RHF 의 handleSubmit 은 비동기 — 검증/변환 후 onSubmit 호출
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0]).toMatchObject({
email: 'a@b.com',
password: 'secret123',
});
});違いが1つ — RHF の handleSubmit は非同期です。検証を非同期で回し、通れば onSubmit を呼びます。だから await waitFor が必要です。onSubmit.mock.calls[0][0] は最初の呼び出しの最初の引数 — RHF は form values を1つのオブジェクトで渡します。
onSubmit.mock.calls[0][1] は RHF の2番目の引数(イベント)。toHaveBeenCalledWith({...}) より toMatchObject の方が安全な理由 — RHF が追加のメタデータを渡すことがあるからです。
ファイルアップロードのテスト #
userEvent.upload の出番。
import { useState, ChangeEvent } from 'react';
export function AvatarUpload() {
const [file, setFile] = useState<File | null>(null);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) setFile(f);
};
return (
<label>
아바타
<input type="file" accept="image/*" onChange={handleChange} />
{file && <p>선택된 파일: {file.name} ({file.size} bytes)</p>}
</label>
);
}it('파일을 업로드하면 파일명과 크기를 표시한다', async () => {
const user = userEvent.setup();
render(<AvatarUpload />);
const file = new File(['hello'], 'avatar.png', { type: 'image/png' });
const input = screen.getByLabelText('아바타') as HTMLInputElement;
await user.upload(input, file);
expect(input.files?.[0]).toBe(file);
expect(screen.getByText(/avatar\.png/)).toBeInTheDocument();
expect(screen.getByText(/5 bytes/)).toBeInTheDocument();
});new File([content], name, options) は標準の Web API。jsdom でもそのままサポートされています。
Submit 中の disabled — 非同期の流れ #
フォームの送信が非同期なら、その間ボタンが disabled になるかも検証する価値があります。
export function AsyncForm({ onSubmit }: { onSubmit: () => Promise<void> }) {
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await onSubmit();
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={submitting}>
{submitting ? '제출 중...' : '제출'}
</button>
</form>
);
}it('제출 중에는 버튼이 disabled 된다', async () => {
const user = userEvent.setup();
let resolve: () => void;
const onSubmit = vi.fn(
() => new Promise<void>((r) => { resolve = r; })
);
render(<AsyncForm onSubmit={onSubmit} />);
const button = screen.getByRole('button', { name: '제출' });
await user.click(button);
// submit 중 — 버튼 disabled
expect(screen.getByRole('button', { name: '제출 중...' })).toBeDisabled();
// promise 해결 → 다시 enabled
resolve!();
await waitFor(() => {
expect(screen.getByRole('button', { name: '제출' })).toBeEnabled();
});
});onSubmit を意図的に止めた promise にして「submitting 状態」を観察します。resolve!() で解決して最後まで進めます。こういう制御された非同期は vi.fn() + 自前の promise でスッキリ書けます。
テストしないこと #
フォームテストが肥大化するよくある原因 — フォームライブラリそのもの をテストしようとすること。
これらは我々のコードではなく RHF/Formik の動作。テストしないでください。
registerが ref を正しく登録するかuseFormの初期値が適用されるかformState.isDirtyが正確か- 検証ルールのメッセージフォーマット
テストすべきもの。
- 我々のコンポーネントがフォームをどう組み立てるか(ラベル、検証ルールの意味、エラー表示位置)
- 我々の検証ロジック — ライブラリがそのルールを適用するとはいえ、ルールのビジネス的な意味は我々のもの
- 送信後の我々のコードの分岐(成功 → ルーティング、失敗 → エラー表示)
#1 の流れ — 我々のコードが壊れたらユーザーが気づくかのフィルター。
よくある落とし穴 #
type が遅すぎる — userEvent.type は1文字ずつ入力します。長いテキストは時間がかかります。テストで入力自体の速度が重要でないなら、user.click(input); fireEvent.change(input, { target: { value: 'long text' } }) のような迂回より paste がスッキリします。
await user.click(input);
await user.paste('long text content');clear の後 type が効かない — clear は input を空にしますが、focus が外れることがあります。その後 type する前にもう一度 user.click(input) するか、user.type(input, '{selectall}{backspace}새 값') で一度に済ませます。
送信後 form values が空オブジェクト — RHF などで register を忘れた input。あるいは name 属性の漏れ。
エラーメッセージが2つ捕まる — 検証を間違えて2つのルールが同時に失敗。role='alert' が2つ。getAllByRole('alert') か within で絞り込みます。
1テストにシナリオが多すぎる — happy + error1 + error2 + recovery が1つの it に全部入っていると、壊れたときどこで壊れたか分かりません。it 単位で分けます。
まとめ #
- 新規に書くテストはほぼ常に
userEvent—fireEventが捕まえられない動作(focus、シーケンス)が多すぎます。 - 毎テストで
userEvent.setup()を1回。await user.action()パターン。 - フォームテストの骨格 — happy path 1 + 検証ルールごと1 + エラー→復帰1。それ以上は普通やりすぎ。
- RHF などのフォームライブラリの上でもパターンは同じ。
handleSubmitが非同期である点だけwaitForで対応。 userEvent.upload、keyboard('{Enter}')、tab()などは、ユーザーがキーボードだけでフォームを使うシナリオへの答え。- フォームライブラリそのもの はテストしないこと。我々のコンポーネントの決定だけ。
- 非同期 submit 中の disabled 検証は、意図的に止めた promise +
vi.fn()で制御。
次の記事(#6 Playwright で E2E と CI 統合)ではトラックの最後。RTL の領域から一段上へ — 本物のブラウザで実際の流れを検証する E2E。Playwright のセットアップ、page object パターン、CI での統合、そしてトラックの総まとめ。