•21 min read

fireEventを使うのをやめよう: React Testing user-event v14ガイド

fireEventを使うのをやめよう: React Testing user-event v14ガイド

自動テストは、回復力の高いフロントエンドソフトウェア開発の基盤です。しかし、長年にわたり、開発者はコンポーネントの内部状態変数を確認したり、プライベートクラスメソッドをテストしたりするなど、実装の詳細をアサートする壊れやすい単体テストを書いてきました。React Testing Libraryは、エンドユーザーの視点からコンポーネントをテストする(内部コンポーネントの状態を検査するのではなく、目に見えるDOM要素と対話する)ことを奨励することで、フロントエンドテストに革命をもたらしました。

Audio Briefing
0:00 / 0:00

この技術ガイドでは、VitestやJestのような最新のテストランナーで@testing-library/user-event v14+を使用するReact Testing Libraryのベストプラクティスを検証します。大規模なコードベースを管理している場合、これらのパターンをVitest Monorepo単体テストパフォーマンスガイドと組み合わせることで、テストスイートを高速に保つことができます。完全なブラウザ検証のために、単体テストとPlaywright E2EテストおよびPlaywrightビジュアルリグレッションテストを組み合わせることで、CI/CDパイプライン全体で不安定さ(flakiness)をゼロにすることができます。user-eventが従来のfireEventメソッドに取って代わる理由、findByクエリを使用して非同期アサーションを構造化する方法、アクセス可能なクエリの優先順位を適用する方法、Mock Service Worker(MSW)を使用してバックエンドネットワークリクエストをクリーンにモックする方法を学びます。

user-eventはfireEventとどのように異なり、実際のユーザーインタラクションをシミュレートするのか?

user-event APIは、フォーカス、ホバー、キープレス、入力イベントを含む現実的なフルブラウザイベントチェーンをシミュレートしますが、fireEventは分離された合成DOMイベントをディスパッチします。人間のユーザーが実際のウェブブラウザでボタンをクリックすると、ブラウザはpointerover、pointerdown、mousedown、focus、pointerup、mouseup、そして最後にclickという一連のイベントを発火します。fireEvent.click()を使用すると、単一の合成clickイベントのみがディスパッチされ、ホバー、フォーカス、入力検証チェックはスキップされます。

user-event vs fireEvent DOM Dispatch Pipeline

対照的に、userEvent.click()はブラウザイベントパイプライン全体をトリガーします。ボタンが無効になっている場合、userEvent.click()は無効属性を尊重し、クリックハンドラの発火を拒否し、実際のブラウザの動作を反映します。無効なボタンでfireEvent.click()を使用すると、クリックコールバックが人為的にトリガーされ、誤った陽性テスト結果につながります。

両方のAPI間のアーキテクチャ上の違いを見てみましょう。

+------------------------------------+------------------------------------+------------------------------------+
| Interaction Aspect                 | fireEvent (Legacy API)             | user-event (Modern v14 API)        |
+------------------------------------+------------------------------------+------------------------------------+
| Event Dispatch Mechanism           | Single synthetic DOM event         | Full realistic browser event chain |
| Disabled Element Enforcement       | Ignores disabled attributes        | Respects disabled DOM element state|
| Form Typing Behavior               | Overwrites value attribute instantly| Triggers keydown, keypress, input  |
| Setup Requirement                  | Direct static function call        | Requires userEvent.setup() instance|
| Async Execution Contract           | Synchronous execution              | Asynchronous Promise execution     |
+------------------------------------+------------------------------------+------------------------------------+

両方のアプローチを使用してフォーム入力コンポーネントをテストするためのコード実装を比較してみましょう。

従来のfireEventを使用したフォーム入力のテスト

// Anti-Pattern: Testing with fireEvent skips realistic browser events
import { render, screen } from '@testing-library/react';
import fireEvent from '@testing-library/user-event';
import { UserForm } from './UserForm';

test('submits form using fireEvent (unrealistic event simulation)', () => {
  render(<UserForm />);

  const input = screen.getByLabelText(/username/i);
  const button = screen.getByRole('button', { name: /submit/i });

  // Instantly overwrites input value without keydown or input events
  fireEvent.change(input, { target: { value: 'john_doe' } });
  fireEvent.click(button);

  expect(screen.getByText(/welcome, john_doe/i)).toBeInTheDocument();
});

最新のuser-event v14を使用したフォーム入力のテスト

次に、userEvent.setup()を使用した慣用的な実装を見てみましょう。

// Recommended Pattern: Testing with userEvent v14 simulates realistic typing
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserForm } from './UserForm';

test('submits form using userEvent (realistic browser event simulation)', async () => {
  // Always call userEvent.setup() before rendering the component!
  const user = userEvent.setup();

  render(<UserForm />);

  const input = screen.getByLabelText(/username/i);
  const button = screen.getByRole('button', { name: /submit/i });

  // Simulates realistic keystrokes, focus changes, and character events
  await user.type(input, 'john_doe');
  await user.click(button);

  expect(await screen.findByText(/welcome, john_doe/i)).toBeInTheDocument();
});

userEvent.setup()がセッションオブジェクトを初期化することに注意してください。userEvent.setup()を呼び出す前にrender()を呼び出す必要があります。これにより、user-eventはドキュメントレベルのリスナーをアタッチし、キーボードナビゲーション操作全体で状態を追跡できます。

さらに、userEvent.setup()は、偽のタイマーを進めたり、ポインタオプションをシミュレートしたりするための構成オプションを受け入れます。

const user = userEvent.setup({
  advanceTimers: vi.advanceTimersByTime,
  delay: null // Disables default typing delay during fast unit test execution
});

delay: nullを構成することで、CI環境でのテストスイートの実行が高速化され、現実的なブラウザイベントディスパッチ動作が維持されます。

Advertisement

エンジニアはfindByクエリとwaitForヘルパーを使用して非同期テストをどのように構造化すべきか?

エンジニアは、DOMに入る要素のfindByクエリを待機し、要素の削除や属性のアサーションにwaitForヘルパーを使用することで、非同期テストを構造化します。非同期テストは、開発者がsetTimeoutのような固定タイマーを使用したり、バックグラウンドネットワークリクエストが完了する前に要素をクエリしようとしたりすると、不安定なテストスイートの頻繁な原因となります。

React Testing Library Async Query Lifecycle

React Testing Libraryは、DOMアサーションのために3つの異なるクエリプレフィックスカテゴリを提供します。

  1. getByクエリセレクタ: getByクエリは、現在のDOM要素に対して同期アサーションを実行します。一致するDOM要素をすぐに返すか、0個または複数の要素が一致する場合はエラーをスローします。要素が初期コンポーネントレンダリングで存在することが保証されている場合に使用します。

  2. queryByクエリセレクタ: queryByクエリは、要素の非存在を同期的にチェックします。一致するDOM要素を返すか、要素が一致しない場合はnullを返します。要素がDOMに存在しないことをアサートする場合(expect(screen.queryByText('Error')).toBeNull())に使用します。

  3. findByクエリセレクタ: findByクエリは、将来のDOM要素に対して非同期ポーリングクエリを実行します。一致する要素が1,000msのタイムアウトウィンドウ内でDOMに表示されたときに解決されるPromiseを返します。非同期APIフェッチまたは状態遷移後に作成された要素を待機する場合に使用します。

バックエンドサーバーからデータをフェッチする非同期ユーザーリストコンポーネントをテストする方法を見てみましょう。

// components/UserList.tsx
import { useState, useEffect } from 'react';

export function UserList() {
  const [users, setUsers] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/users')
      .then((res) => {
        if (!res.ok) throw new Error('Network error');
        return res.json();
      })
      .then((data) => {
        setUsers(data.map((u: any) => u.name));
        setIsLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setIsLoading(false);
      });
  }, []);

  if (isLoading) {
    return <div role="status">Loading user directory...</div>;
  }

  if (error) {
    return <div role="alert">Failed to fetch user directory: {error}</div>;
  }

  return (
    <ul>
      {users.map((name) => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  );
}

以下は、findByクエリとwaitForElementToBeRemovedを使用したクリーンな非同期テストスイートです。

// components/UserList.test.tsx
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import { UserList } from './UserList';

test('renders loading spinner and displays fetched user directory', async () => {
  render(<UserList />);

  // Assert loading indicator is visible synchronously
  const loadingIndicator = screen.getByRole('status');
  expect(loadingIndicator).toHaveTextContent(/loading user directory/i);

  // Option A: Wait for loading element to disappear from DOM
  await waitForElementToBeRemoved(() => screen.queryByRole('status'));

  // Option B: Await findBy query for async element appearance
  const firstUser = await screen.findByText('Alice Smith');
  expect(firstUser).toBeInTheDocument();
});

findByクエリを使用すると、テストスイートでの固定のsleep()遅延がなくなります。クエリは、要素が表示されるかタイムアウトが切れるまで50msごとにDOMをポーリングし、テスト実行を高速かつ信頼性の高いものに保ちます。

React Testing Libraryにおけるアクセス可能なクエリセレクタの優先順位ルールとは?

アクセス可能なクエリセレクタの優先順位ルールは、ユーザーや支援技術がウェブアプリケーションをナビゲートする方法を反映するために、まずgetByRoleとgetByLabelTextを使用することを義務付けています。テストを作成する際には、アクセス可能な代替手段が不可能な場合を除き、CSSクラス名、HTML要素タグ、または内部テストIDで要素を選択することは避けてください。ARIAロールと可視ラベルを介してテストすることで、アプリケーションがスクリーンリーダーやキーボードユーザーにとってアクセス可能な状態を維持できます。

React Testing Library Accessible Query Priority Hierarchy

React Testing Libraryの公式クエリ優先順位を見てみましょう。

+-----------------------------------------------------------------------------------+
| React Testing Library Query Selector Priority Hierarchy                           |
+-----------------------------------------------------------------------------------+
| Priority 1: Queries Accessible to Everyone                                       |
|  - getByRole (e.g., getByRole('button', { name: /save/i }))                       |
|  - getByLabelText (e.g., getByLabelText(/email address/i))                        |
|  - getByPlaceholderText (e.g., getByPlaceholderText(/search/i))                   |
|  - getByText (e.g., getByText(/submit form/i))                                   |
|  - getByDisplayValue (e.g., getByDisplayValue('john_doe'))                        |
+-----------------------------------------------------------------------------------+
| Priority 2: Semantic HTML Queries                                                 |
|  - getByAltText (e.g., getByAltText(/company logo/i))                            |
|  - getByTitle (e.g., getByTitle(/close dialog/i))                                |
+-----------------------------------------------------------------------------------+
| Priority 3: Test IDs (Last Resort Only)                                           |
|  - getByTestId (e.g., getByTestId('custom-canvas-node'))                          |
+-----------------------------------------------------------------------------------+

優先順位ルールを適用すると、開発中にアクセシビリティのバグがどのように検出されるかを見てみましょう。

// Bad accessible markup: Missing label association
export function BadForm() {
  return (
    <div>
      <span>Username</span>
      <input type="text" id="user-input" />
      <button onClick={() => {}}>Submit</button>
    </div>
  );
}

// Good accessible markup: Explicit HTML label association
export function GoodForm() {
  return (
    <form>
      <label htmlFor="username-field">Username</label>
      <input type="text" id="username-field" />
      <button type="submit">Submit Form</button>
    </form>
  );
}

BadFormのテストをscreen.getByLabelText(/username/i)を使用して作成しようとすると、<span>要素がHTML <label>ではないため、テストはエラーをスローします。getByLabelTextを使用してテストを作成すると、ソフトウェア開発者はコードが本番環境に到達する前にHTMLアクセシビリティマークアップを修正せざるを得なくなります。

要素に暗黙的なARIAロール(一般的な<div>など)がない場合は、roleまたはaria-label属性を追加して、スクリーンリーダーユーザーとテストクエリセレクタの両方にとって要素をアクセス可能にします。これにより、スクリーンリーダーに依存するユーザーにとって、より包括的なアプリケーションを構築できます。

テストで非同期サーバーアクションとマイクロサービス依存関係をモックする方法は?

Mock Service Worker MSWを使用して、HTTP境界でネットワーク層の呼び出しをインターセプトすることで、非同期サーバーアクションとマイクロサービス依存関係をモックします。内部のJavaScript関数やfetch実装を直接モックする代わりに、Mock Service WorkerはService WorkerプロセスまたはNode.jsリクエストインターセプターを設定し、ネットワーク層でネットワークリクエストをキャプチャします。

ネットワーク境界でモックすることで、単体テスト中にアプリケーションコードが実際のHTTPフェッチのシリアル化、ヘッダー解析、エラー処理ロジックを実行することが保証されます。MSWを使用する場合、Reactの内部フックをスタブする必要はありません。

ReactアプリケーションのテストスイートにMSWハンドラを設定する方法を見てみましょう。

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // Intercept GET requests to /api/users
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: 'Alice Smith' },
      { id: '2', name: 'Bob Jones' }
    ]);
  }),

  // Intercept POST requests to /api/users
  http.post('/api/users', async ({ request }) => {
    const body = (await request.json()) as { name: string };

    if (!body.name) {
      return new HttpResponse('Name field is required.', { status: 400 });
    }

    return HttpResponse.json(
      { id: '3', name: body.name },
      { status: 201 }
    );
  })
];

以下は、VitestまたはJestテスト環境用のMSWサーバー構成ファイルです。

// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

最後に、MSWサーバーをクリーンに起動および停止するようにグローバルテストセットアップファイルを構成しましょう。

// setupTests.ts
import '@testing-library/jest-dom';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';

// Start server before running test suites
beforeAll(() => server.listen());

// Reset inline request handler overrides after each test
afterEach(() => server.resetHandlers());

// Clean up server after tests finish
afterAll(() => server.close());

これで、個々のテストファイル内でMSWハンドラをインラインでオーバーライドすることで、サーバーエラー時のUI動作をアサートする統合テストを作成できます。

// components/UserForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { UserForm } from './UserForm';

test('displays server error banner when API submission fails', async () => {
  // Override default MSW handler for this specific test case
  server.use(
    http.post('/api/users', () => {
      return new HttpResponse('Internal Server Error', { status: 500 });
    })
  );

  const user = userEvent.setup();
  render(<UserForm />);

  await user.type(screen.getByLabelText(/username/i), 'new_user');
  await user.click(screen.getByRole('button', { name: /submit/i }));

  // Assert error banner appears on 500 response
  const alert = await screen.findByRole('alert');
  expect(alert).toHaveTextContent(/internal server error/i);
});

MSWを使用してネットワークリクエストをモックすることで、コンポーネントテストはバックエンドサーバーのデプロイから切り離され、現実的なHTTPレスポンス解析が保証されます。フロントエンドテストスイートは数秒で実行され、アプリケーションの信頼性に対する完全な信頼を維持できます。

GraphQLクエリのモックをMSWハンドラを使用して処理する方法も見てみましょう。

// mocks/graphqlHandlers.ts
import { graphql, HttpResponse } from 'msw';

export const graphqlHandlers = [
  graphql.query('GetUserProfile', () => {
    return HttpResponse.json({
      data: {
        user: {
          id: 'usr_99',
          name: 'Sarah Connor',
          email: 'sarah@example.com'
        }
      }
    });
  })
];

RESTとGraphQL APIの両方にMSWを統合することで、エンジニアリングチームは単体テスト、統合テスト、E2Eテストスイート全体で統一されたネットワークモックを維持できます。MSWハンドラを使用する場合、異なるテストランナー間でモック定義を重複させる必要はありません。

以下は、異なるフロントエンド抽象化レイヤーにおける単体テスト戦略を対比する要約表です。

+------------------------------------+------------------------------------+------------------------------------+
| Testing Layer Aspect               | Isolated Unit Component Test       | MSW Network Integration Test       |
+------------------------------------+------------------------------------+------------------------------------+
| Mocking Strategy                   | Function prop stubs & Jest mocks   | Network-level MSW HTTP interception|
| Execution Speed                    | Extremely Fast (< 10 ms per test)  | Very Fast (< 50 ms per test)       |
| Refactoring Resilience             | Low (Breaks on prop signature edit)| High (Resilient to internal refactor)|
| Confidence Level                   | Moderate (Tests components in isolation)| High (Tests full component lifecycle)|
+------------------------------------+------------------------------------+------------------------------------+

浅いコンポーネントプロップスタブよりもMSWネットワーク統合テストを優先することで、ソフトウェアチームは積極的なコードリファクタリングサイクルに耐える堅牢なテストスイートを構築します。MSWモックハンドラが複数年にわたる製品ライフサイクルでテストメンテナンスコストを大幅に削減することを確認しました。さらに、ブラウザベースのPlaywrightテスト内でMSWモックサーバーを実行することで、ローカルコンポーネント単体テストとエンドツーエンドリグレッションスイート全体で同一のネットワーク動作が保証されます。これはエンジニアリングの速度にとって大きな勝利であり、開発者は不安定なネットワークモックと戦うのに何時間も費やす必要がありません。ソフトウェアリリースサイクルは予測可能でスムーズになり、本番ウェブデプロイ全体で徹底的に検証されることがわかります。

Advertisement

おすすめ記事

よくある質問

user-eventは、無効化されたDOM属性を尊重しながら、完全で現実的なブラウザイベントシーケンス(ホバー、ポインターダウン、マウスダウン、フォーカス、ポインターアップ、マウスアップ、クリックなど)をディスパッチします。対照的に、fireEventは、コンパニオンイベントを発火したり、要素の状態を強制したりすることなく、分離された合成DOMイベントをディスパッチするため、誤った陽性テストパスにつながります。

userEvent.setup()は、クリップボード、キーボードの状態、ポインターの位置、イベントリスナーを初期化します。setup()をrender()の前に呼び出すことで、userEventインスタンスがアクティブなドキュメントに適切にバインドされ、テスト内の複数のユーザーアクション全体で現実的なシーケンシャル状態が維持されます。

Promise、API呼び出し、またはタイマーの後に要素が非同期で表示される場合は常に、findByクエリ(例:findByRole、findByText)を使用してください。findByクエリはPromiseを返し、内部でwaitForを使用してデフォルトの1000msタイムアウトで自動的に再試行します。getByは、初期レンダリング時に同期的に存在することが保証されている要素にのみ使用してください。

Tabキーを押すことをシミュレートするには、await user.tab()を使用します。user-eventはDOMのtabIndexシーケンスを計算し、document.activeElementを次のフォーカス可能なノードに移動し、それぞれの入力で適切なフォーカスイベントとブラーイベントを発火します。


関連ガイドと詳細


知識チェック

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement