Next.jsのUIコンポーネントやカスタムフックに対して自動テストを書いている際、「トースト通知が5秒後に自動的に消える挙動をテストしたいが、実際のテスト実行中にも本当に5秒間待機しなければならないのか?」とストレスを感じたことはないでしょうか。
また、new Date() など実行時点のシステム現在時刻に依存するロジックが、深夜や月初・月末、あるいはCIサーバーを実行するタイミングによって稀に失敗する 「Flaky Test(不安定なテスト)」 に悩まされたことはありませんか。
これらの問題は、テスト環境におけるタイマー機能(setTimeout, setInterval)や時刻システムをモック化する fake timers(疑似タイマー) を正しく利用することで、すべて解決できます。この記事では、JestおよびVitestにおいて時間を完全に支配し、テストを爆速かつ100%確実にするための実装方法を徹底的に解説します。
1. なぜ「時間の待機」を伴う自動テストは失敗しやすいのか?#
テストコード内で実時間の待機(await new Promise(r => setTimeout(r, 5000)) など)を行うと、以下の3つの重大なデメリットが発生します。
- テストスイート全体の実行速度が低下する このようなリアルタイム待機が数十箇所に増えるだけで、テスト全体の実行完了までに数分〜数十分を要するようになり、CIのビルド待ち時間が膨れ上がります。
- CPUの負荷状況に依存してテスト結果が変動する(Flakiness)
CIサーバーの負荷が高い状態で実行されると、JavaScriptイベントループの遅延により
setTimeoutの実行が数ミリ秒遅れ、アサーション(期待値の検証)のタイミングと合わずにランダムにテストが落ちるようになります。 - 日付切り替わり時のバグを検知しにくい 「うるう年」「年末年始」「月末」など、極端な境界日付でしか発生しないバグをローカルテストで再現・担保することが困難になります。
疑似タイマー(fake timers)は、実時間の流れをシミュレーション上のクロックに置き換え、**「タイムトラベル」**させることでこれらの不確実性を完全に排除します。
2. 実践:Jest でのモダンな Fake Timers のセットアップ#
Next.jsプロジェクトで広く採用されている Jest では、現在 @sinonjs/fake-timers をベースとした “modern” 実装が標準となっています。
基本的なテンプレートと setTimeout のテスト#
import { render, screen, act } from '@testing-library/react';
import { AutoDismissAlert } from './AutoDismissAlert';
describe('AutoDismissAlert', () => {
beforeEach(() => {
// 1. 各テストケースの実行前に疑似タイマーを有効化(引数に 'modern' を指定)
jest.useFakeTimers();
});
afterEach(() => {
// 2. 他のテストへの影響を防ぐため、実時間に確実にリセットする
jest.useRealTimers();
});
it('指定された5秒後にアラートメッセージが自動消去されること', () => {
render(<AutoDismissAlert message="保存しました" duration={5000} />);
// 初期表示を確認
expect(screen.getByText('保存しました')).toBeInTheDocument();
// ❌ 実際に5秒待つのではなく
// ✅ 疑似クロック上で瞬時に 5000ms 先に進める
act(() => {
jest.advanceTimersByTime(5000);
});
// 待機後のDOM状態を確認
expect(screen.queryByText('保存しました')).not.toBeInTheDocument();
});
});
act()で囲むことの重要性: タイマーを進行させたことによる React のステート更新およびレンダリング処理は、必ず@testing-library/reactのact()関数ブロック内で実行しなければなりません。これを怠ると、コンソールにWarning: An update to Component... inside a test was not wrapped in act(...)という警告が出力されます。
3. 「システム時刻」を固定して Flaky Test を防ぐ方法#
特定のイベント開催期間中にのみ表示されるバナーコンポーネントや、クーポンの有効期限判定といった「現在時刻」に強く依存するロジックをテストするには、setSystemTime を使って基準時間を任意のポイントに固定(静止)します。
describe('CampaignBanner', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('キャンペーン期間外(2026年1月2日)ではバナーが表示されないこと', () => {
// 疑似システム現在時刻をキャンペーン前の日付に固定
jest.setSystemTime(new Date('2026-01-02T10:00:00Z'));
render(<CampaignBanner />);
expect(screen.queryByText('新春セール開催中!')).not.toBeInTheDocument();
});
it('キャンペーン期間内(2026年1月3日)に入るとバナーが表示されること', () => {
// 疑似システム現在時刻をキャンペーン期間中の日付に固定
jest.setSystemTime(new Date('2026-01-03T12:00:00Z'));
render(<CampaignBanner />);
expect(screen.getByText('新春セール開催中!')).toBeInTheDocument();
});
});4. 実務での最難関:非同期処理(Promise/Async)とタイマーの共存#
実務のテストで最も開発者を悩ませるのが、「setTimeout のコールバック処理の内部で、さらに非同期 API 通信(Promise)が発生している」 という複雑なケースです。
単に jest.advanceTimersByTime を呼ぶだけでは、JavaScriptのマイクロタスクキュー(Promise)の処理順序の関係で、非同期処理の完了が DOM アサーションに間に合わない状態が発生します。
解決策:Promise キューのフラッシュを強制する#
この問題をクリアするには、疑似タイマーを進めた直後に、保留中のすべてのマイクロタスクを即時に消化(フラッシュ)させるためのヘルパー処理を挟み込みます。
it('API通信を伴う遅延タイマー処理のテスト', async () => {
jest.useFakeTimers();
render(<AsyncDelayComponent />);
// トリガーアクションを実行
const button = screen.getByRole('button', { name: '実行' });
button.click();
// 1. タイマーを3秒進める
act(() => {
jest.advanceTimersByTime(3000);
});
// 2. 【最重要】保留中の Promise の解決を強制的に完了させるためのフラッシュ処理
await act(async () => {
await Promise.resolve(); // マイクロタスクの最下部に潜り込み、前のタスクをすべて押し出す
});
// 非同期API結果が画面に反映されていることを検証
expect(await screen.findByText('非同期処理完了')).toBeInTheDocument();
});5. Vitest を使用したモダンな Fake Timers の作法#
近年、Next.js プロジェクトにおいて Jest から Vitest への移行が増加しています。Vitest でも API 設計は非常によく似ていますが、非同期処理を含むタイマー制御においてより直感的な API が提供されています。
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
describe('VitestでのFake Timers', () => {
beforeEach(() => {
// Vitestでのタイマーモック化
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('Vitest特有の非同期アドバンス機能を利用する', async () => {
// 疑似時間を固定
vi.setSystemTime(new Date('2026-05-31'));
render(<DelayedComponent />);
// ✅ Vitestでは advanceTimersByTimeAsync を使うことで、
// タイマーの進行と非同期Promiseキューのフラッシュを1アクションで安全に同時に行えます。
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('遅延読み込み完了')).toBeInTheDocument();
});
});6. よくある質問(FAQ)#
Q. jest.runAllTimers() と jest.advanceTimersByTime() の使い分けは?#
A. runAllTimers() はキューに入っているすべてのタイマー処理を最後まで一気に実行します。非常に手軽ですが、コンポーネント内部で setInterval が永続ループしているような場合に、無限ループに陥ってテストランナーがフリーズする原因になります。そのため、実務においては、明確に何ミリ秒進めるかを指定できる advanceTimersByTime() の使用を強く推奨します。
Q. useFakeTimers() を設定しているのに new Date() の時間がテスト中に変化しません。#
A. JestやVitestの疑似タイマーは、デフォルトでシステムクロックの自動進行を一時停止させます。タイマー進行と連動して new Date() の時間も進めたい場合は、jest.useFakeTimers({ advanceTimeDelta: 20 }) などのオプションを指定してクロック進行を有効化するか、advanceTimersByTime をコールして時間を進める必要があります。
Q. useRealTimers() を afterEach で呼び忘れるとどうなりますか?#
A. 他のファイルで実行される別のテストケースでも、システム現在時刻が狂ったままになったり、非同期通信(Axios等)の内部で使用されているタイマーまでモック化され、テストスイート全体が予期せぬ挙動でランダムに壊れ始める重大な原因になります。必ず afterEach や afterAll 内で確実に useRealTimers() を呼び出し、元の実環境に戻す設計を徹底してください。











