【テクニカル・上級編】 @testing-library/user-eventによるイベントシミュレーション – React実践ガイド

テストは「動作」を模倣するのではない、「ユーザー」をシミュレートするのだ

Reactのテスティングにおいて、多くのエンジニアがいまだに `fireEvent` の亡霊に取り憑かれているのを目にする。しかし、もし君が堅牢で、かつ「壊れない」アプリケーションのアーキテクトを目指すなら、今すぐその古い習慣を捨てるべきだ。

`fireEvent` はただの DOM イベントディスパッチャーに過ぎない。対して `@testing-library/user-event` は、ブラウザのイベントループとユーザーの操作体験を抽象化したレイヤーだ。なぜこれを使うのか? それは、単なる `click` イベントを発火させるだけでは、現実のブラウザで発生する「一連の複雑なイベント連鎖」を再現できないからに他ならない。

なぜ fireEvent では不十分なのか

`fireEvent` を使うと、例えば「キー入力」において `keydown`, `keypress`, `keyup` の順序や、フォーカスの状態遷移、あるいは `onChange` の発火タイミングといった、現実のブラウザエンジンが裏側で行っている泥臭い処理をすっ飛ばしてしまう。

結果として、開発環境ではテストが通るのに、本番環境で「日本語入力の確定時にイベントが発火しない」「モーダルが開いた瞬間のオートフォーカスで競合が起きる」といったバグに悩まされることになる。これは非同期処理の競合やレンダリング負荷のタイミングが、単なるイベント発火とは異なるレイヤーで発生しているからだ。

user-event による実戦的なシミュレーション

`user-event` は `async` / `await` を前提としている。これは、イベントがマイクロタスクキューを通過し、React のレンダリングサイクルを巻き込みながら処理されることを意味する。

以下のコードは、入力フォームにおける「フォーカス→タイピング→送信」という一連の流れを、より現実的な挙動でテストする例だ。

import { render, screen } from ‘@testing-library/react’;
import userEvent from ‘@testing-library/user-event’;
import MyForm from ‘./MyForm’;

test(‘ユーザーがフォームに入力し、送信ボタンを押すまでの挙動を再現’, async () => {
// ユーザーのインスタンスを生成(セットアップにはコストがかかるため、一度だけ実行するのが定石)
const user = userEvent.setup();

render();

const input = screen.getByLabelText(/ユーザー名/i);
const button = screen.getByRole(‘button’, { name: /送信/i });

// clickやtypeは内部で複数のイベントを非同期に発火させる
// これにより、ReactのState更新とレンダリングの競合を自然な形で検知できる
await user.click(input);
await user.type(input, ‘Architech_Geek’);

// ここで初めてクリックをシミュレート
// 単なるイベント発火ではなく、ホバーやフォーカスの遷移を伴う「人間らしい」挙動が行われる
await user.click(button);

// 送信後のレンダリング結果をアサート
expect(await screen.findByText(/送信完了/i)).toBeInTheDocument();
});

メモリ効率とレンダリングサイクルへの洞察

上級エンジニアとして注目すべきは、`user-event` が内部でどのように React のレンダリングを制御しているかだ。

`user.type()` を叩くと、一文字ごとにイベントが発行され、その都度コンポーネントが再レンダリングされる。もし君のコンポーネントが `memo` 化されていなかったり、不必要な副作用(`useEffect`)を抱えていたりすれば、テストの実行時間すら極端に長くなる。

これは「テストが遅い」という問題ではない。「コンポーネントの再レンダリングコストが異常に高い」という、本番環境のUXを損なう兆候をテストが早期に警告してくれているのだ。

重大なバグを回避するためのTips

1. `userEvent.setup()` の再利用: テストケースごとにセットアップを行うとメモリ負荷が増大する。共通の `user` インスタンスを保持するアーキテクチャを設計しよう。
2. `findBy` クエリの活用: `user-event` は非同期だ。要素の出現を待機する `findBy` クエリと組み合わせることで、Race Condition(競合状態)を意図的に引き起こし、堅牢なエラーハンドリングを実装できる。
3. `keyboard` APIの精緻な制御: 特殊キー(Enter, Tab, Escape)のシミュレーションは、モーダルやドロップダウンの制御バグを炙り出す特効薬だ。

結びに代えて

テストコードは、単なる品質保証のツールではない。それは、君が設計したコンポーネントが「ブラウザという過酷な環境でどう振る舞うべきか」を定義する、もう一つの仕様書だ。

`fireEvent` で妥協するな。`user-event` を使い込み、ブラウザエンジンの挙動をハックする感覚でテストを書く。そうすれば、君が書いた React コンポーネントは、どんなに複雑な非同期状態に晒されても、決して崩れない強固な城壁となるはずだ。

さあ、エディタに戻って、その「人間味のないテスト」を「ユーザーの鼓動を感じるテスト」に書き換えてくるといい。

コメント

タイトルとURLをコピーしました