테스팅 강좌 #5 사용자 이벤트와 폼 테스트 — userEvent의 역할

10 분 소요

#3 에서 클릭을 한 번씩 짚었고, #4 에서 데이터 fetch를 익혔습니다. 이번 글의 주제는 사용자 입력. 폼은 항상 테스트가 까다롭게 느껴지는 영역이지만, 실은 패턴이 두세 개로 좁혀집니다.

테스팅 강좌에서 이번 글의 위치:

이번 글은 userEvent의 깊이 + 폼 라이브러리 위의 테스트 패턴.

userEvent vs fireEvent — 진짜 차이 #

#3 에서 짚은 결을 한 번 더 깊게.

**fireEvent**는 단일 DOM 이벤트를 발사. fireEvent.click(el)은 “click 이벤트 한 개” 를 dispatch. 사용자가 실제로 클릭할 때 일어나는 다른 일들 — focus 변화, mousedown/mouseup, hover — 은 모두 빠져요.

**userEvent**는 그 동작들의 시퀀스를 다 시뮬레이션. 클릭 한 번이 사실 focus → mousedown → mouseup → click 순서고, userEvent는 그 모두를 발사합니다.

click 한 번의 실제 시퀀스
사용자 클릭
focus 이벤트 (만약 elememnt 가 focusable)
mousedown
mouseup
click

# fireEvent.click 은 마지막 click 만
# userEvent.click 은 4 단계 모두

차이가 드러나는 경우:

focus 변화에 의존하는 경우
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>
  );
}
fireEvent 로는 못 잡는 동작
// 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() — 한 번에 한 인스턴스 #

매 테스트에서 셋업 호출.

setup 패턴
it('어떤 동작', async () => {
  const user = userEvent.setup();
  render(<MyComponent />);
  await user.click(...);
});

setup()이 만드는 user 인스턴스는 internal pointer 위치, 키보드 modifier 상태(Shift, Cmd 누름 여부)를 들고 있습니다. 매 테스트마다 새 인스턴스를 만들어야 이전 테스트의 상태가 새지 않습니다.

여러 테스트가 같은 셋업을 공유한다면 helper로 묶을 수도 있습니다.

공통 setup helper
function setup(jsx: React.ReactElement) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
}

it('...', async () => {
  const { user } = setup(<MyComponent />);
  await user.click(...);
});

자주 쓰는 메소드들 #

userEvent의 주요 메소드를 한 표에.

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을 본격적으로 테스트해 봅니다.

src/components/LoginForm.tsx
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>
  );
}

테스트 — 시나리오별로:

LoginForm.test.tsx
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)를 자주 씁니다. 테스트의 결은 거의 같지만 한두 가지만 다름.

LoginForm — RHF 버전
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>
  );
}

테스트는 거의 동일:

RHF 테스트
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의 역할입니다.

src/components/AvatarUpload.tsx
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가 되는지도 검증할 가치가 있습니다.

src/components/AsyncForm.tsx
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');

cleartype 안 됐다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 단위로 분리.

정리 #

  • 새로 짜는 테스트는 거의 항상 userEventfireEvent가 못 잡는 동작(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 에서의 통합, 그리고 트랙의 회수.

X