~17 min · vitest-components, user-event, interaction
Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Click 이벤트 발동하지 마. Click 해."
user-event 가 존재하는 이유
fireEvent.click(button) 은 단일 DOM click 이벤트 디스패치. 진짜 사용자가 하는 게 그게 아냐. 진짜 클릭은 시퀀스 — pointerdown, mousedown, pointerup, mouseup, click, focus 전환, 키보드 트리거면 가끔 keydown. Focus, double-click 감지, pointer 이벤트 처리하는 코드는 프로덕션에서 깨지면서 fireEvent 테스트는 통과할 수 있어.
user-event 는 전체 시퀀스 시뮬레이션. await user.click(button) 가 전체 이벤트 체인을 맞는 순서로 걸어 — fireEvent 가 절대 못 보는 버그 잡아.
Setup 패턴 (user-event v14+)
v14+ 에서는 테스트당 한 번 userEvent.setup() 호출 (보통 render 직후) 해서 user 인스턴스 얻어. 모든 인터랙션이 그 인스턴스 통해 가, 어느 element 가 현재 focus 갖는지 같은 내부 state 관리해.
모든 메서드 async. await user.click(...), await user.type(input, 'hello'), await user.keyboard('{Enter}'). await 잊는 게 가장 흔한 실수 — 테스트가 돈 것처럼 읽히는데 실제론 안 돌아.
일상에서 쓰는 인터랙션들
user.click(element) — 일꾼.
user.type(input, 'text') — 문자씩 타이핑, 각 키에 맞는 이벤트 발동. 특수 키는 {Backspace}, {Enter}, {Shift>}A{/Shift}.
user.clear(input) — 전체 선택 + 삭제 (loop 안의 type('{Backspace}') 보다 더 안정적).
user.keyboard('{Enter}') — 타겟 없이 키보드 이벤트 발동; focus 가진 거 사용.
user.tab() — focus 앞으로 이동; user.tab({ shift: true }) 로 뒤로.
user.hover(element) / user.unhover(element) — hover 전용 UI 에.
테스트가 사용자 스토리처럼 읽히면 아마 맞는 거야."사용자가 이메일 타이핑, submit 클릭, 성공 메시지 봄" 이 user-event 호출 세 개와 단언 하나에 거의 직접 매핑돼. 테스트가 내부 API 코레오그래피처럼 읽히면 한 발 물러나서 사용자가 하는 거 중심으로 다시 짜.
`fireEvent` 가 값어치할 때
fireEvent 는 deprecated 아냐 — user-event 자연 대응이 없는 이벤트에 맞는 도구. 예: non-interactive element 의 synthetic 이벤트 (예: scroll 이나 resize 시뮬레이션), 또는 전체 사용자 시퀀스 없이 단일 이벤트 처리하는 코드 테스트를 명시적으로 원할 때. UI 테스트 95% 는 user-event default.
Code
user-event v14+ — 테스트당 setup(), 매 호출 await·tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './login-form';
describe('<LoginForm />', () => {
it('submits the form when the user clicks Sign in', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(
screen.getByRole('textbox', { name: /email/i }),
'pippa@example.com'
);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith('pippa@example.com');
});
it('also submits on Enter inside the email field', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(
screen.getByRole('textbox', { name: /email/i }),
'pippa@example.com{Enter}'
);
expect(onSubmit).toHaveBeenCalledWith('pippa@example.com');
});
});
키보드 테스트 — tab + focus 단언·tsx
// Keyboard navigation — testing tab order
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('cycles focus through the form fields in order', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={() => {}} />);
// No element has focus initially (no autoFocus prop).
await user.tab();
expect(screen.getByRole('textbox', { name: /email/i })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: /sign in/i })).toHaveFocus();
// Shift+Tab to go back
await user.tab({ shift: true });
expect(screen.getByRole('textbox', { name: /email/i })).toHaveFocus();
});
`user.clear` — 한 호출, Backspace 체조 없음·tsx
// Clear vs type({Backspace}) — clear is more reliable
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('clears and replaces the input', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={() => {}} />);
const email = screen.getByRole('textbox', { name: /email/i });
await user.type(email, 'oldemail@example.com');
expect(email).toHaveValue('oldemail@example.com');
// Clear: selects all + deletes. One call, no off-by-one.
await user.clear(email);
expect(email).toHaveValue('');
await user.type(email, 'new@example.com');
expect(email).toHaveValue('new@example.com');
});
파일 업로드 — `user.upload`·tsx
// File upload — for <input type="file">
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('accepts an uploaded image', async () => {
const user = userEvent.setup();
render(<AvatarUploader onUpload={(f) => console.log(f.name)} />);
const file = new File(['fake image bytes'], 'avatar.png', { type: 'image/png' });
const input = screen.getByLabelText(/upload avatar/i);
await user.upload(input, file);
expect((input as HTMLInputElement).files?.[0]).toBe(file);
expect((input as HTMLInputElement).files).toHaveLength(1);
});
<Counter /> 컴포넌트 만들어 — +, -, Reset 버튼과 현재 count 표시. user-event 로 테스트: (1) + 세 번 클릭하면 3 표시, (2) 0 에서 - 클릭해도 음수 안 됨 (컴포넌트가 clamp), (3) Reset 이 0 으로 되돌림, (4) 키보드 + 나 - (user.keyboard 통해) 도 wire 했으면 작동. 모든 인터랙션이 userEvent.setup() 와 await 통해.
Hint
Count 가 <span> 안 텍스트로 나타나면 getByText 로 정확한 숫자 쿼리 — 또는 <output aria-label="count"> 로 wrap 해서 role 로 쿼리하면 더 깔끔한 테스트.
Progress
Progress is local-only — sign in to sync across devices.