Testing #5 — User Events and Form Tests: Where userEvent Belongs
In #3 we touched on clicks, and in #4 we covered the place of data fetching. This post is about user input. Forms always feel tricky to test, but the patterns actually narrow down to two or three.
Here is where this post sits in the Testing series:
- #1 Why Test
- #2 Vitest Setup and Your First Unit Test
- #3 React Testing Library
- #4 Async and Network Mocking — MSW
- #5 User Events and Form Tests — Where userEvent Belongs ← this post
- #6 E2E with Playwright and CI Integration
This post goes deep on userEvent plus the test patterns on top of form libraries.
userEvent vs fireEvent — the real difference
#
Let’s revisit the distinction we made in #3 more deeply.
fireEvent dispatches a single DOM event. fireEvent.click(el) dispatches “one click event.” Everything else that happens when a user actually clicks — focus changes, mousedown/mouseup, hover — is missing.
userEvent simulates the full sequence at those places. A single click is actually focus → mousedown → mouseup → click, and userEvent dispatches all of them.
사용자 클릭
│
▼
focus 이벤트 (만약 elememnt 가 focusable)
│
▼
mousedown
│
▼
mouseup
│
▼
click
# fireEvent.click 은 마지막 click 만
# userEvent.click 은 4 단계 모두Where the difference shows up:
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();Places like this are not rare. Input masking, autocomplete, hover preview, focus trap — none of them get caught by a single event. For new tests, userEvent is almost always the answer.
userEvent.setup() — one instance per test
#
Call setup in every test.
it('어떤 동작', async () => {
const user = userEvent.setup();
render(<MyComponent />);
await user.click(...);
});The user instance created by setup() holds internal pointer position and keyboard modifier state (whether Shift or Cmd is being pressed). You need a fresh instance in each test so that state from a previous test doesn’t leak in.
If multiple tests share the same setup, you can wrap it in a helper.
function setup(jsx: React.ReactElement) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
it('...', async () => {
const { user } = setup(<MyComponent />);
await user.click(...);
});Methods you’ll use often #
The main methods of userEvent in one place.
// 클릭
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]);Special keys inside type use the curly-brace notation:
{Enter} {Tab} {Escape} {Backspace}
{Delete} {Space} {ArrowLeft} {ArrowRight}
{ArrowUp} {ArrowDown}LoginForm — testing a whole form #
Let’s properly test the LoginForm that briefly appeared in #1.
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>
);
}Tests, scenario by scenario:
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);
});
});This pattern is the skeleton of almost every form test.
- 1 happy path
- 1 per validation rule (email, password, …)
- 1 error → recovery scenario
That’s enough to cover the form’s behavior. Anything more is usually overtesting.
Submitting with Enter — the keyboard flow
#
You may need to verify users who fill out the form using only the keyboard.
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();
});Pressing {Enter} inside an input naturally fires the form’s onSubmit — that’s the browser’s default behavior. It works without any extra handling.
Tests on top of React Hook Form #
In production you’ll often use a form library (React Hook Form, Formik). The test pattern is almost the same, with only one or two places different.
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>
);
}The test is almost identical:
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',
});
});One difference — RHF’s handleSubmit is asynchronous. It runs validation asynchronously, and on pass calls onSubmit. That’s why await waitFor is needed. onSubmit.mock.calls[0][0] is the first argument of the first call — RHF passes the form values as one object.
onSubmit.mock.calls[0][1] is RHF’s second argument (the event). toMatchObject is safer than toHaveBeenCalledWith({...}) because RHF can pass additional metadata.
File upload tests #
The place for 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) is a standard Web API. jsdom supports it as-is.
Disabled while submitting — async flow #
If a form submission is asynchronous, it’s worth verifying that the button becomes disabled during that time.
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();
});
});We deliberately stall onSubmit with a paused promise to observe the “submitting state.” Calling resolve!() lets it run to completion. This kind of controlled async is clean with vi.fn() plus a hand-built promise.
What not to test #
A common cause of bloated form tests — trying to test the form library itself.
These belong to RHF/Formik’s behavior, not our code. Don’t test:
- Whether
registercorrectly registers a ref - Whether
useForm’s initial values are applied - Whether
formState.isDirtyis accurate - The format of validation rule messages
What to test:
- How our component assembles the form (labels, the meaning of the validation rules, where errors appear)
- Our validation logic — the library applies the rules, but the business meaning of those rules is ours
- The branches in our code after submit (success → routing, failure → error display)
The thread from #1 — the filter of would the user notice if our code broke.
Common pitfalls #
type is too slow — userEvent.type types one character at a time. Long text takes time. If the input speed itself doesn’t matter to the test, paste is cleaner than workarounds like user.click(input); fireEvent.change(input, { target: { value: 'long text' } }).
await user.click(input);
await user.paste('long text content');type after clear didn’t work — clear empties the input, but focus may come off. Before calling type again, do another user.click(input) or use user.type(input, '{selectall}{backspace}새 값') to do it in one step.
Empty form values after submit — a missed register (in RHF) or a missing name attribute on an input.
Two error messages caught — validation went wrong and two rules failed simultaneously. There are two role='alert'. Narrow it with getAllByRole('alert') or within.
Too many scenarios in one test — when happy + error1 + error2 + recovery are all in one it, you can’t tell where it broke. Split per it.
Wrap-up #
- Almost always use
userEventfor new tests — there are too many places (focus, sequence)fireEventcan’t catch. - One
userEvent.setup()per test. The pattern isawait user.action(). - Form test skeleton: 1 happy path + 1 per validation rule + 1 error → recovery. More than that is usually overkill.
- The pattern stays the same on top of form libraries like RHF. Just handle the fact that
handleSubmitis async withwaitFor. userEvent.upload,keyboard('{Enter}'),tab(), and the like are the answer for the scenarios where users operate the form with the keyboard alone.- Don’t test the form library itself. Test only the decisions of our component.
- Verifying disabled-while-async-submit is best controlled with a deliberately stalled promise plus
vi.fn().
In the next post (#6 E2E with Playwright and CI Integration) we close out the track — stepping up from RTL to E2E that verifies real flows in a real browser. It covers Playwright setup, the page object pattern, CI integration, and a recap of the track.