테스팅 강좌 #5 사용자 이벤트와 폼 테스트 — userEvent의 역할
#3 에서 클릭을 한 번씩 짚었고, #4 에서 데이터 fetch를 익혔습니다. 이번 글의 주제는 사용자 입력. 폼은 항상 테스트가 까다롭게 느껴지는 영역이지만, 실은 패턴이 두세 개로 좁혀집니다.
테스팅 강좌에서 이번 글의 위치:
- #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 이벤트 한 개” 를 dispatch. 사용자가 실제로 클릭할 때 일어나는 다른 일들 — focus 변화, mousedown/mouseup, hover — 은 모두 빠져요.
**userEvent**는 그 동작들의 시퀀스를 다 시뮬레이션. 클릭 한 번이 사실 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();이런 경우가 한두 개가 아닙니다. 입력 마스킹, 자동 완성, hover preview, focus trap — 모두 단일 이벤트로는 안 잡히는 결입니다. **새로 짜는 테스트는 거의 항상 userEvent**가 답입니다.
userEvent.setup() — 한 번에 한 인스턴스
#
매 테스트에서 셋업 호출.
it('어떤 동작', async () => {
const user = userEvent.setup();
render(<MyComponent />);
await user.click(...);
});setup()이 만드는 user 인스턴스는 internal pointer 위치, 키보드 modifier 상태(Shift, Cmd 누름 여부)를 들고 있습니다. 매 테스트마다 새 인스턴스를 만들어야 이전 테스트의 상태가 새지 않습니다.
여러 테스트가 같은 셋업을 공유한다면 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();
});{Enter}를 input 안에서 누르면 form의 onSubmit이 자연스럽게 발사됩니다 — 브라우저의 기본 동작입니다. 별도 처리 없이 동작.
React Hook Form 위의 테스트 #
실전에서는 폼 라이브러리(React Hook Form, Formik)를 자주 씁니다. 테스트의 결은 거의 같지만 한두 가지만 다름.
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',
});
});차이 하나 — RHF의 handleSubmit은 비동기. 검증을 비동기로 돌리고, 통과하면 onSubmit 호출. 그래서 await waitFor가 필요합니다. onSubmit.mock.calls[0][0]은 첫 호출의 첫 인자 — RHF가 form values를 한 객체로 넘깁니다.
onSubmit.mock.calls[0][1]은 RHF의 두 번째 인자(이벤트). 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은 한 글자씩 입력. 긴 텍스트는 시간이 걸림. 테스트에서 입력 자체 속도가 중요한 게 아니면 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를 빠뜨린 경우입니다. 또는 name 속성 누락입니다.
에러 메시지가 두 개 잡힘 — 검증을 잘못해서 두 룰이 동시에 실패. role=‘alert’ 가 두 개. getAllByRole('alert') 또는 within으로 좁힘.
한 테스트에 너무 많은 시나리오 — happy + error1 + error2 + recovery가 한 it에 다 있으면 — 깨졌을 때 어디서 깨졌는지 모름. it 단위로 분리.
정리 #
- 새로 짜는 테스트는 거의 항상
userEvent—fireEvent가 못 잡는 동작(focus, sequence)이 너무 많음. - 매 테스트에서
userEvent.setup()한 번.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 에서의 통합, 그리고 트랙의 회수.