12k
All articles

テストにおける時間の扱い方:非同期処理と遅延のための信頼性の高いパターン

Jest、Vitest、React Testing Library、Playwright、Cypressで生じるタイマー起因の不安定なテストをフェイクタイマーパターンで解消する。

OpenReplay Team
OpenReplay Team
テストにおける時間の扱い方:非同期処理と遅延のための信頼性の高いパターン

時間はテストの信頼性を密かに破壊する要因です。ローカルで成功したテストがCIで失敗する。数秒で完了していたテストスイートが今では数分かかる。完璧に動作するコンポーネントが、タイマーとPromiseが協調しないために幻のような失敗を示す。

本記事では、Jest、Vitest、React Testing Library、Playwright、Cypressにおける最新の非同期テストタイミングパターンフェイクタイマーのベストプラクティスを、2025年に実際に機能するものに焦点を当てて解説します。

重要なポイント

  • Promise/タイマーのデッドロックを防ぐため、同期的なバリアントではなく非同期タイマーAPI(advanceTimersByTimeAsync)を使用する
  • Testing Libraryの非同期ユーティリティとフェイクタイマーを組み合わせる場合は、自動進行タイマーを設定する
  • テストの汚染と連鎖的な失敗を防ぐため、必ずafterEachでフェイクタイマーをクリーンアップする
  • E2Eテストでは、waitForTimeoutcy.wait(ms)のような任意の遅延ではなく、特定の条件を待つ

なぜ時間がテストを壊すのか

テストが時間に関連して失敗する理由は3つあります:実際の遅延が実行を遅くする、タイミングのばらつきが不安定性を引き起こす、そしてフェイクタイマーがPromiseと競合する。

JavaScriptのイベントループは、マクロタスク(setTimeoutsetInterval)とマイクロタスク(Promiseのコールバック)を特定の順序で処理します。フェイクタイマーはマクロタスクをインターセプトしますが、マイクロタスクを自動的に処理しません。この不一致が、タイマー関連のテスト失敗のほとんどを引き起こします。

Jest非同期タイマー:モダンなアプローチ

Jestの現在のフェイクタイマー実装は、内部で@sinonjs/fake-timersを使用しています。信頼性の高い非同期テストの鍵は、どのAPIを一緒に使用するかを理解することです。

beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers();
});

test('debounced search triggers after delay', async () => {
  render(<SearchInput />);
  await userEvent.type(screen.getByRole('textbox'), 'query');
  
  await jest.advanceTimersByTimeAsync(300);
  
  expect(mockSearch).toHaveBeenCalledWith('query');
});

重要な詳細:advanceTimersByTimeの代わりにadvanceTimersByTimeAsyncを使用します。非同期バリアントはタイマー実行の間にマイクロタスクをフラッシュし、テストのハングを引き起こすPromise/タイマーのデッドロックを防ぎます。

アニメーションの場合、Jestは実際のフレームタイミングを待たずにrequestAnimationFrameコールバックをステップ実行するためのjest.advanceTimersToNextFrame()を提供しています。

Vitestフェイクタイマー:Sinon統合

Vitestは同じ@sinonjs/fake-timers基盤を共有しているため、VitestフェイクタイマーはわずかなAPI差異を除いてJestと同様に動作します。

import { vi } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

test('toast disappears after timeout', async () => {
  render(<Toast message="Saved" />);
  
  await vi.advanceTimersByTimeAsync(3000);
  
  expect(screen.queryByText('Saved')).not.toBeInTheDocument();
});

両フレームワークとも自動進行タイマーをサポートしており、イベントループがアイドル状態になるときに自動的に時間を進めます。これにより、ほとんどのテストで手動のtick管理が不要になります。

React Testing Libraryとの連携

フェイクタイマーとTesting LibrarywaitForの相互作用は、頻繁に混乱を引き起こします。フェイクタイマーが有効な場合、waitForはポーリングしますが時間が進まず、無限ループが発生します。

解決策:非同期待機中に自動的に進行するようフェイクタイマーを設定します。

jest.useFakeTimers({ advanceTimers: true });

これにより、Promiseが保留中のときにタイマーを進めるようJestに指示し、waitForfindByクエリがフェイクタイマーと自然に連携できるようになります。

user-eventの場合、現在のAPIを使用していることを確認してください。このライブラリは内部で独自のタイミングを処理し、正しく設定されている場合はフェイクタイマーと連携します。

E2E時間制御:PlaywrightとCypress

Playwrightpage.clock APIは、ブラウザコンテキストで正確な時間制御を提供します:

await page.clock.install({ time: new Date('2025-01-15') });
await page.clock.fastForward(5000);

Cypressは同様の制御のためにcy.clock()cy.tick()を提供しています。どちらのアプローチも、実際の遅延と不安定性をもたらすwaitForTimeoutcy.wait(ms)よりも望ましいです。

不安定性を引き起こす一般的な落とし穴

テスト間のタイマーリーク:afterEachで必ず実際のタイマーを復元してください。リークしたフェイクタイマーは、後続のテストで連鎖的な失敗を引き起こします。

サードパーティライブラリとの競合:アニメーションフレームワークやポーリングユーティリティなどのライブラリは、独自のタイマーをスケジュールします。これらはフェイクタイマーのインストールを尊重しない可能性があり、予期しない動作を引き起こします。

マイクロタスク/マクロタスクの順序:setTimeoutPromise.resolve()を混在させるコードは、フェイクタイマー下では異なる動作をします。非同期タイマーAPIを一貫して使用してください。

Temporal APIの考慮事項:Dateの代わりにTemporalを使用する新しいコードベースには、別のモック戦略が必要です。テストをフレームワークに依存しないものにするため、時間ソースを抽象化してください。

まとめ

同期バリアントよりも非同期タイマーAPI(advanceTimersByTimeAsync)を優先してください。Testing Libraryの非同期ユーティリティを使用する場合は、自動進行を設定してください。E2Eテストでは任意のスリープやwaitForTimeoutを避け、代わりに特定の条件を待ってください。テストの汚染を防ぐため、必ずフェイクタイマーをクリーンアップしてください。

目標は、高速に実行され、一貫して成功し、タイミング実装の詳細をエンコードしないテストです。自動進行フェイクタイマーがその大部分を実現します。それ以外のすべてについては、明示的な非同期連携が任意の遅延に勝ります。

よくある質問

フェイクタイマーとasync/awaitを使用するとテストがハングするのはなぜですか?

フェイクタイマーがマクロタスクをブロックし、Promiseがマイクロタスクの解決を待つときにテストがハングします。advanceTimersByTimeの代わりにadvanceTimersByTimeAsyncのような非同期タイマーメソッドを使用してください。非同期バリアントはタイマー実行の間にマイクロタスクをフラッシュし、Promiseとタイマーのデッドロックを解消します。

JestフェイクタイマーでwaitForを動作させるにはどうすればよいですか?

jest.useFakeTimers({ advanceTimers: true })を呼び出してadvanceTimersオプションを有効にしてJestを設定します。これにより、Promiseが保留中のときに自動的に時間を進めるようJestに指示し、waitForとfindByクエリが無限ループを作成せずに正しくポーリングできるようになります。

E2Eテストでは実際の遅延とフェイクタイマーのどちらを使用すべきですか?

Playwrightのpage.clockまたはCypressのcy.clockとcy.tickを介してフェイクタイマーを使用してください。waitForTimeoutやcy.waitを使用した実際の遅延は、実行速度が環境によって異なるため不安定性をもたらします。代わりに、特定のUI条件を待つか、制御された時間進行を使用してください。

テストがローカルでは成功するのにCIではタイミングエラーで失敗するのはなぜですか?

CI環境にはCPUとメモリリソースの変動があり、タイミング依存のテストが一貫性のない動作をします。実際の遅延をフェイクタイマーに置き換え、固定期間ではなく条件を待つ非同期アサーションを使用し、テスト間の汚染を防ぐために適切なタイマークリーンアップを確保してください。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.