C.W.K.
Stream
Lesson 03 of 04 · published

user-event: 사람처럼 행동하기

~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 에.
  • user.selectOptions(select, value) — <select> / role=combobox 에.
  • user.upload(input, file) — 파일 input 에.
테스트가 사용자 스토리처럼 읽히면 아마 맞는 거야. "사용자가 이메일 타이핑, 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);
});

External links

Exercise

<Counter /> 컴포넌트 만들어 — +, -, Reset 버튼과 현재 count 표시. user-event 로 테스트: (1) + 세 번 클릭하면 3 표시, (2) 0 에서 - 클릭해도 음수 안 됨 (컴포넌트가 clamp), (3) Reset0 으로 되돌림, (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.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.