テスト講座 #5 ユーザーイベントとフォームテスト — userEvent の出番

読了 11分

#3 でクリックを一度ずつ押さえ、#4 でデータフェッチを学びました。今回のテーマは ユーザー入力。フォームはいつもテストが面倒に感じる領域ですが、実はパターンは2、3個に絞られます。

テスト講座 におけるこの記事の位置づけ。

この記事は userEvent の深掘り + フォームライブラリ上でのテストパターン。

userEvent vs fireEvent — 本当の違い #

#3 で押さえたところをもう一段深く。

fireEvent は単一の DOM イベントを発火します。fireEvent.click(el) は「click イベント1個」を dispatch するだけ。ユーザーが実際にクリックしたときに起こる他のこと — focus の変化、mousedown/mouseup、hover — は全部抜け落ちます。

userEvent はその場のシーケンスを全部シミュレーションします。1回のクリックは実際には 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();

このようなケースは1つや2つではありません。入力マスキング、オートコンプリート、hover プレビュー、focus トラップ — どれも単一イベントでは捕まえられない流れです。新規に書くテストはほぼ常に userEvent が答えです。

userEvent.setup() — 1テストに1インスタンス #

毎テストで setup を呼びます。

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

setup() が作る user インスタンスは、internal pointer の位置やキーボード modifier の状態(Shift、Cmd を押しているか)を保持しています。前のテストの状態が漏れないよう、毎テストで新しいインスタンスを作る必要があります。

複数のテストが同じ setup を共有するなら、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();
});

input の中で {Enter} を押すと、フォームの onSubmit が自然に発火します — ブラウザのデフォルト動作です。特別な処理なしで動きます。

React Hook Form の上でのテスト #

実戦ではフォームライブラリ(React Hook Form、Formik)をよく使います。テストの流れはほぼ同じですが、1、2箇所だけ違います。

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',
  });
});

違いが1つ — RHF の handleSubmit は非同期です。検証を非同期で回し、通れば onSubmit を呼びます。だから await waitFor が必要です。onSubmit.mock.calls[0][0] は最初の呼び出しの最初の引数 — RHF は form values を1つのオブジェクトで渡します。

onSubmit.mock.calls[0][1] は RHF の2番目の引数(イベント)。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 は1文字ずつ入力します。長いテキストは時間がかかります。テストで入力自体の速度が重要でないなら、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 を忘れた input。あるいは name 属性の漏れ。

エラーメッセージが2つ捕まる — 検証を間違えて2つのルールが同時に失敗。role='alert' が2つ。getAllByRole('alert')within で絞り込みます。

1テストにシナリオが多すぎる — happy + error1 + error2 + recovery が1つの it に全部入っていると、壊れたときどこで壊れたか分かりません。it 単位で分けます。

まとめ #

  • 新規に書くテストはほぼ常に userEventfireEvent が捕まえられない動作(focus、シーケンス)が多すぎます。
  • 毎テストで userEvent.setup() を1回。await user.action() パターン。
  • フォームテストの骨格 — happy path 1 + 検証ルールごと1 + エラー→復帰1。それ以上は普通やりすぎ。
  • RHF などのフォームライブラリの上でもパターンは同じ。handleSubmit が非同期である点だけ waitFor で対応。
  • userEvent.uploadkeyboard('{Enter}')tab() などは、ユーザーがキーボードだけでフォームを使うシナリオへの答え。
  • フォームライブラリそのもの はテストしないこと。我々のコンポーネントの決定だけ。
  • 非同期 submit 中の disabled 検証は、意図的に止めた promise + vi.fn() で制御。

次の記事(#6 Playwright で E2E と CI 統合)ではトラックの最後。RTL の領域から一段上へ — 本物のブラウザで実際の流れを検証する E2E。Playwright のセットアップ、page object パターン、CI での統合、そしてトラックの総まとめ。

X