T-CREATOR

Jest で setTimeout・setInterval をテストするコツ

Jest で setTimeout・setInterval をテストするコツ

フロントエンド開発でタイマー関数を使った機能をテストする際、実時間を待つのは非効率的ですし、テストの安定性も損なわれます。Jest のタイマーモック機能を使えば、時間に依存する処理を瞬時にテストできるようになります。

この記事では、setTimeout や setInterval を使った処理の効果的なテスト方法について、実践的なコード例とともに詳しく解説していきます。よくあるエラーの解決策や、高度なテクニックまで幅広くカバーしますので、ぜひ最後までお読みください。

タイマー関数テストの必要性

実時間待機の問題点

従来のテスト方法では、タイマー関数を含むコードをテストする際に実際の時間を待つ必要がありました。これには多くの問題があります。

typescript// 実時間を待つ非効率なテスト例
test('3秒後にメッセージが表示される', async () => {
  render(<DelayedMessage />);

  // 実際に3秒待つ必要がある
  await new Promise((resolve) => setTimeout(resolve, 3000));

  expect(
    screen.getByText('表示されました')
  ).toBeInTheDocument();
});

このテスト方法では、1 つのテストで 3 秒かかってしまい、複数のタイマーテストがあると実行時間が膨大になってしまいます。また、タイムアウト値が長い場合(例:30 秒)には、テストが実用的でなくなってしまいますね。

タイマーモックの利点

Jest のタイマーモック機能を使用することで、以下のメリットが得られます:

項目実時間待機タイマーモック
テスト実行時間実際の待機時間瞬時
テストの安定性環境に依存安定
デバッグのしやすさ困難容易
CI/CD での実行時間がかかる高速

特に大規模なプロジェクトでは、この差が開発体験に大きく影響します。

Jest のタイマーモック基礎

useFakeTimers の基本設定

Jest でタイマーをモックするには、jest.useFakeTimers() を使用します。まずは基本的な設定方法から見ていきましょう。

typescriptdescribe('タイマーテストの基本', () => {
  beforeEach(() => {
    // 各テスト前にフェイクタイマーを有効化
    jest.useFakeTimers();
  });

  afterEach(() => {
    // テスト後にクリーンアップ
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
  });

  test('setTimeout の基本テスト', () => {
    const callback = jest.fn();

    setTimeout(callback, 1000);

    // 1秒経過をシミュレート
    jest.advanceTimersByTime(1000);

    expect(callback).toHaveBeenCalledTimes(1);
  });
});

この基本パターンでは、beforeEach でフェイクタイマーを設定し、afterEach でクリーンアップを行っています。jest.advanceTimersByTime() を使って時間の経過をシミュレートできます。

モダンタイマーとレガシータイマー

Jest 27 以降では、デフォルトでモダンタイマーが使用されますが、設定を明示的に指定することも可能です。

typescriptdescribe('タイマー設定のオプション', () => {
  test('モダンタイマーの使用', () => {
    jest.useFakeTimers('modern');

    const callback = jest.fn();
    setTimeout(callback, 500);

    jest.advanceTimersByTime(500);
    expect(callback).toHaveBeenCalled();

    jest.useRealTimers();
  });

  test('レガシータイマーの使用', () => {
    jest.useFakeTimers('legacy');

    const callback = jest.fn();
    setTimeout(callback, 500);

    jest.runTimersToTime(500);
    expect(callback).toHaveBeenCalled();

    jest.useRealTimers();
  });
});

モダンタイマーでは advanceTimersByTime() を、レガシータイマーでは runTimersToTime() を使用します。新しいプロジェクトではモダンタイマーを推奨します。

主要 API の概要

Jest のタイマーモックには、様々な API が用意されています。それぞれの用途と使い分けを理解することが重要です。

typescriptdescribe('タイマーAPI の使い分け', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  test('advanceTimersByTime - 指定時間だけ進める', () => {
    const callback = jest.fn();

    setTimeout(callback, 1000);
    setTimeout(callback, 2000);

    // 1秒だけ進める
    jest.advanceTimersByTime(1000);
    expect(callback).toHaveBeenCalledTimes(1);

    // さらに1秒進める
    jest.advanceTimersByTime(1000);
    expect(callback).toHaveBeenCalledTimes(2);
  });

  test('runAllTimers - 全てのタイマーを実行', () => {
    const callback = jest.fn();

    setTimeout(callback, 1000);
    setTimeout(callback, 5000);
    setTimeout(callback, 10000);

    // 全てのタイマーを一度に実行
    jest.runAllTimers();
    expect(callback).toHaveBeenCalledTimes(3);
  });
});

このように、テストの目的に応じて適切な API を選択することで、効率的なテストが書けるようになります。

実践的なテストパターン

コンポーネントの遅延処理テスト

React コンポーネントでよくある自動保存機能やローディング表示のテストを見てみましょう。

typescript// AutoSaveInput.tsx
import React, { useState, useEffect } from 'react';

interface AutoSaveInputProps {
  onSave: (value: string) => void;
  delay?: number;
}

export const AutoSaveInput: React.FC<
  AutoSaveInputProps
> = ({ onSave, delay = 1000 }) => {
  const [value, setValue] = useState('');
  const [saving, setSaving] = useState(false);

  useEffect(() => {
    if (!value) return;

    setSaving(true);
    const timer = setTimeout(() => {
      onSave(value);
      setSaving(false);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay, onSave]);

  return (
    <div>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder='入力してください'
      />
      {saving && <span>保存中...</span>}
    </div>
  );
};

このコンポーネントをテストする際は、実際に 1 秒待つのではなく、タイマーモックを活用します。

typescript// AutoSaveInput.test.tsx
import {
  render,
  screen,
  fireEvent,
} from '@testing-library/react';
import { AutoSaveInput } from './AutoSaveInput';

describe('AutoSaveInput', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  test('入力後1秒でonSaveが呼ばれる', () => {
    const mockSave = jest.fn();
    render(<AutoSaveInput onSave={mockSave} />);

    const input =
      screen.getByPlaceholderText('入力してください');

    // テキストを入力
    fireEvent.change(input, {
      target: { value: 'テスト入力' },
    });

    // まだ保存されていないことを確認
    expect(mockSave).not.toHaveBeenCalled();

    // 1秒経過をシミュレート
    jest.advanceTimersByTime(1000);

    // 保存が実行されたことを確認
    expect(mockSave).toHaveBeenCalledWith('テスト入力');
  });

  test('連続入力時は最後の入力のみ保存される', () => {
    const mockSave = jest.fn();
    render(<AutoSaveInput onSave={mockSave} />);

    const input =
      screen.getByPlaceholderText('入力してください');

    // 連続で入力
    fireEvent.change(input, { target: { value: 'あ' } });
    jest.advanceTimersByTime(500);

    fireEvent.change(input, { target: { value: 'あい' } });
    jest.advanceTimersByTime(500);

    fireEvent.change(input, {
      target: { value: 'あいう' },
    });

    // 最後の入力から1秒経過
    jest.advanceTimersByTime(1000);

    // 最後の値のみ保存されることを確認
    expect(mockSave).toHaveBeenCalledTimes(1);
    expect(mockSave).toHaveBeenCalledWith('あいう');
  });
});

デバウンス処理の動作確認も、タイマーモックを使えば瞬時にテストできます。連続入力時の挙動も正確に検証できますね。

API 通信のタイムアウト処理

API 通信でよくあるタイムアウト処理のテストパターンを見てみましょう。

typescript// apiClient.ts
export class ApiClient {
  private timeout: number;

  constructor(timeout = 5000) {
    this.timeout = timeout;
  }

  async fetchUser(userId: string): Promise<User> {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        reject(new Error('Request timeout'));
      }, this.timeout);

      // 実際のAPI呼び出しをシミュレート
      fetch(`/api/users/${userId}`)
        .then((response) => {
          clearTimeout(timer);
          if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
          }
          return response.json();
        })
        .then(resolve)
        .catch(reject);
    });
  }
}

この API クライアントのタイムアウト処理をテストする場合:

typescript// apiClient.test.ts
import { ApiClient } from './apiClient';

// fetchをモック
global.fetch = jest.fn();

describe('ApiClient', () => {
  beforeEach(() => {
    jest.useFakeTimers();
    (fetch as jest.Mock).mockClear();
  });

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

  test('タイムアウト時にエラーが発生する', async () => {
    // fetchが永続的に待機するようにモック
    (fetch as jest.Mock).mockImplementation(
      () => new Promise(() => {}) // 永続待機
    );

    const client = new ApiClient(3000);
    const promise = client.fetchUser('123');

    // 3秒経過をシミュレート
    jest.advanceTimersByTime(3000);

    await expect(promise).rejects.toThrow(
      'Request timeout'
    );
  });

  test('正常レスポンス時はタイムアウトしない', async () => {
    const mockUser = { id: '123', name: 'テストユーザー' };

    (fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUser),
    });

    const client = new ApiClient(3000);
    const promise = client.fetchUser('123');

    // レスポンスが返される前に少し時間を進める
    jest.advanceTimersByTime(1000);

    const result = await promise;
    expect(result).toEqual(mockUser);
  });
});

このテストでは、実際に 5 秒待つことなく、タイムアウト処理の動作を確認できています。

定期実行処理のテスト

setInterval を使った定期実行処理のテストも見てみましょう。

typescript// HealthChecker.ts
export class HealthChecker {
  private intervalId: NodeJS.Timeout | null = null;
  private isRunning = false;

  constructor(
    private checkInterval = 30000,
    private onHealthCheck = () => {}
  ) {}

  start(): void {
    if (this.isRunning) return;

    this.isRunning = true;
    this.intervalId = setInterval(() => {
      this.performHealthCheck();
    }, this.checkInterval);
  }

  stop(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
    this.isRunning = false;
  }

  private performHealthCheck(): void {
    this.onHealthCheck();
  }

  getStatus(): boolean {
    return this.isRunning;
  }
}

定期実行処理のテストでは、複数回の実行を素早く検証できます:

typescript// HealthChecker.test.ts
import { HealthChecker } from './HealthChecker';

describe('HealthChecker', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  test('30秒間隔でヘルスチェックが実行される', () => {
    const mockHealthCheck = jest.fn();
    const checker = new HealthChecker(
      30000,
      mockHealthCheck
    );

    checker.start();

    // 最初は実行されていない
    expect(mockHealthCheck).not.toHaveBeenCalled();

    // 30秒経過
    jest.advanceTimersByTime(30000);
    expect(mockHealthCheck).toHaveBeenCalledTimes(1);

    // さらに30秒経過
    jest.advanceTimersByTime(30000);
    expect(mockHealthCheck).toHaveBeenCalledTimes(2);

    // さらに30秒経過
    jest.advanceTimersByTime(30000);
    expect(mockHealthCheck).toHaveBeenCalledTimes(3);

    checker.stop();
  });

  test('stop()で定期実行が停止する', () => {
    const mockHealthCheck = jest.fn();
    const checker = new HealthChecker(
      10000,
      mockHealthCheck
    );

    checker.start();

    // 10秒経過して1回実行
    jest.advanceTimersByTime(10000);
    expect(mockHealthCheck).toHaveBeenCalledTimes(1);

    // 停止
    checker.stop();

    // さらに時間が経過しても実行されない
    jest.advanceTimersByTime(20000);
    expect(mockHealthCheck).toHaveBeenCalledTimes(1);
  });
});

実際に 90 秒待つことなく、瞬時に定期実行の動作を検証できています。

よくある課題と解決策

テスト実行時間の問題

タイマーテストでよく遭遇するエラーと解決策を見ていきましょう。

無限ループエラー

typescript// 問題のあるテスト例
test('runAllTimers で無限ループエラー', () => {
  jest.useFakeTimers();

  // 無限に繰り返すタイマー
  const infiniteTimer = () => {
    setTimeout(infiniteTimer, 1000);
  };

  infiniteTimer();

  // エラー: Maximum call stack size exceeded
  jest.runAllTimers(); // これは危険!
});

この問題の解決策:

typescript// 解決策: advanceTimersByTime を使用
test('無限タイマーの安全なテスト', () => {
  jest.useFakeTimers();

  const callback = jest.fn();

  const infiniteTimer = () => {
    callback();
    setTimeout(infiniteTimer, 1000);
  };

  infiniteTimer();

  // 特定の時間だけ進める
  jest.advanceTimersByTime(5000);

  // 5回実行されたことを確認
  expect(callback).toHaveBeenCalledTimes(5);

  jest.useRealTimers();
});

runAllTimers() は無限ループに陥る可能性があるため、advanceTimersByTime() を使って制御された時間の進行を行いましょう。

メモリリークの防止

typescriptdescribe('メモリリーク防止のパターン', () => {
  let cleanup: (() => void)[] = [];

  beforeEach(() => {
    jest.useFakeTimers();
    cleanup = [];
  });

  afterEach(() => {
    // 全てのタイマーをクリア
    cleanup.forEach((fn) => fn());
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
  });

  test('タイマーのクリーンアップ', () => {
    const callback = jest.fn();

    // タイマーIDを保存してクリーンアップできるようにする
    const timerId = setTimeout(callback, 1000);
    cleanup.push(() => clearTimeout(timerId));

    const intervalId = setInterval(callback, 500);
    cleanup.push(() => clearInterval(intervalId));

    jest.advanceTimersByTime(2000);

    expect(callback).toHaveBeenCalledTimes(5); // interval: 4回 + timeout: 1回
  });
});

非同期処理との競合

タイマーと非同期処理が混在する場合の注意点:

typescript// 問題が起きやすいパターン
test('非同期処理との競合問題', async () => {
  jest.useFakeTimers();

  const asyncCallback = jest.fn();

  setTimeout(async () => {
    await new Promise((resolve) =>
      setTimeout(resolve, 100)
    );
    asyncCallback();
  }, 1000);

  jest.advanceTimersByTime(1000);

  // この時点ではまだ asyncCallback は実行されていない可能性
  expect(asyncCallback).toHaveBeenCalled(); // 失敗する可能性
});

解決策:

typescript// 解決策: flushPromises utility の使用
const flushPromises = () =>
  new Promise((resolve) => setImmediate(resolve));

test('非同期処理との競合解決', async () => {
  jest.useFakeTimers();

  const asyncCallback = jest.fn();

  setTimeout(async () => {
    await new Promise((resolve) => setImmediate(resolve));
    asyncCallback();
  }, 1000);

  jest.advanceTimersByTime(1000);

  // 全ての Promise を解決
  await flushPromises();

  expect(asyncCallback).toHaveBeenCalled();

  jest.useRealTimers();
});

クリーンアップ処理

React の useEffect フックでタイマーを使用する場合のテストパターン:

typescript// useTimer.ts
import { useEffect, useRef } from 'react';

export const useTimer = (
  callback: () => void,
  delay: number,
  enabled = true
) => {
  const callbackRef = useRef(callback);
  const timerRef = useRef<NodeJS.Timeout>();

  // コールバックを最新に保つ
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    if (!enabled) {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
      return;
    }

    timerRef.current = setTimeout(() => {
      callbackRef.current();
    }, delay);

    // クリーンアップ関数
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [delay, enabled]);
};

このフックのテスト:

typescript// useTimer.test.ts
import { renderHook } from '@testing-library/react';
import { useTimer } from './useTimer';

describe('useTimer', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  test('コンポーネントアンマウント時にタイマーがクリアされる', () => {
    const callback = jest.fn();

    const { unmount } = renderHook(() =>
      useTimer(callback, 1000, true)
    );

    // 500ms経過
    jest.advanceTimersByTime(500);

    // アンマウント
    unmount();

    // さらに時間が経過してもコールバックは実行されない
    jest.advanceTimersByTime(1000);

    expect(callback).not.toHaveBeenCalled();
  });

  test('enabled が false の場合タイマーは動作しない', () => {
    const callback = jest.fn();

    renderHook(() => useTimer(callback, 1000, false));

    jest.advanceTimersByTime(2000);

    expect(callback).not.toHaveBeenCalled();
  });
});

高度なテクニック

カスタムタイマーの作成

複雑なタイマー処理をテストするためのカスタムユーティリティを作成してみましょう。

typescript// TimerManager.ts
export class TimerManager {
  private timers: Map<string, NodeJS.Timeout> = new Map();
  private callbacks: Map<string, () => void> = new Map();

  setTimer(
    id: string,
    callback: () => void,
    delay: number
  ): void {
    this.clearTimer(id);

    this.callbacks.set(id, callback);
    const timer = setTimeout(() => {
      callback();
      this.timers.delete(id);
      this.callbacks.delete(id);
    }, delay);

    this.timers.set(id, timer);
  }

  clearTimer(id: string): void {
    const timer = this.timers.get(id);
    if (timer) {
      clearTimeout(timer);
      this.timers.delete(id);
      this.callbacks.delete(id);
    }
  }

  clearAllTimers(): void {
    this.timers.forEach((timer) => clearTimeout(timer));
    this.timers.clear();
    this.callbacks.clear();
  }

  getActiveTimerIds(): string[] {
    return Array.from(this.timers.keys());
  }

  hasActiveTimer(id: string): boolean {
    return this.timers.has(id);
  }
}

このタイマーマネージャーのテスト:

typescript// TimerManager.test.ts
import { TimerManager } from './TimerManager';

describe('TimerManager', () => {
  let timerManager: TimerManager;

  beforeEach(() => {
    jest.useFakeTimers();
    timerManager = new TimerManager();
  });

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

  test('複数のタイマーを管理できる', () => {
    const callback1 = jest.fn();
    const callback2 = jest.fn();
    const callback3 = jest.fn();

    timerManager.setTimer('timer1', callback1, 1000);
    timerManager.setTimer('timer2', callback2, 2000);
    timerManager.setTimer('timer3', callback3, 3000);

    expect(timerManager.getActiveTimerIds()).toHaveLength(
      3
    );

    // 1秒経過
    jest.advanceTimersByTime(1000);
    expect(callback1).toHaveBeenCalled();
    expect(timerManager.getActiveTimerIds()).toHaveLength(
      2
    );

    // さらに1秒経過(合計2秒)
    jest.advanceTimersByTime(1000);
    expect(callback2).toHaveBeenCalled();
    expect(timerManager.getActiveTimerIds()).toHaveLength(
      1
    );

    // さらに1秒経過(合計3秒)
    jest.advanceTimersByTime(1000);
    expect(callback3).toHaveBeenCalled();
    expect(timerManager.getActiveTimerIds()).toHaveLength(
      0
    );
  });

  test('同じIDのタイマーは上書きされる', () => {
    const callback1 = jest.fn();
    const callback2 = jest.fn();

    timerManager.setTimer('same-id', callback1, 1000);
    timerManager.setTimer('same-id', callback2, 2000);

    // 1秒経過 - 最初のタイマーは実行されない
    jest.advanceTimersByTime(1000);
    expect(callback1).not.toHaveBeenCalled();

    // 2秒経過 - 2番目のタイマーが実行される
    jest.advanceTimersByTime(1000);
    expect(callback2).toHaveBeenCalled();
  });
});

パフォーマンステスト

大量のタイマーを使用する処理のパフォーマンステストも可能です。

typescript// PerformanceTimer.ts
export class PerformanceTimer {
  private startTime: number = 0;
  private endTime: number = 0;
  private measurements: number[] = [];

  start(): void {
    this.startTime = Date.now();
  }

  end(): number {
    this.endTime = Date.now();
    const duration = this.endTime - this.startTime;
    this.measurements.push(duration);
    return duration;
  }

  getAverageTime(): number {
    if (this.measurements.length === 0) return 0;

    const sum = this.measurements.reduce(
      (a, b) => a + b,
      0
    );
    return sum / this.measurements.length;
  }

  reset(): void {
    this.measurements = [];
  }
}

パフォーマンステストの実装:

typescript// PerformanceTimer.test.ts
import { PerformanceTimer } from './PerformanceTimer';

describe('PerformanceTimer', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  test('大量タイマーのパフォーマンス測定', () => {
    const timer = new PerformanceTimer();
    const callbacks: jest.Mock[] = [];

    timer.start();

    // 1000個のタイマーを作成
    for (let i = 0; i < 1000; i++) {
      const callback = jest.fn();
      callbacks.push(callback);
      setTimeout(callback, Math.random() * 5000);
    }

    timer.end();

    // 全てのタイマーを実行
    jest.runAllTimers();

    // 全てのコールバックが実行されたことを確認
    callbacks.forEach((callback) => {
      expect(callback).toHaveBeenCalled();
    });

    // パフォーマンス測定結果を確認
    const duration = timer.getAverageTime();
    expect(duration).toBeGreaterThan(0);
  });

  test('タイマー作成のスケーラビリティ', () => {
    const timer = new PerformanceTimer();
    const testSizes = [100, 500, 1000, 2000];

    testSizes.forEach((size) => {
      timer.start();

      // 指定数のタイマーを作成
      for (let i = 0; i < size; i++) {
        setTimeout(() => {}, Math.random() * 1000);
      }

      const duration = timer.end();

      // タイマー作成時間の記録
      console.log(
        `${size}個のタイマー作成時間: ${duration}ms`
      );

      // 全てのタイマーをクリア
      jest.runAllTimers();
    });

    expect(timer.getAverageTime()).toBeGreaterThan(0);
  });
});

この方法で、大量のタイマーを使用するアプリケーションの性能特性を把握できます。

まとめ

Jest のタイマーモック機能を活用することで、時間に依存する処理を効率的にテストできるようになります。本記事で紹介した内容をまとめると以下のようになります。

得られる効果

開発効率の向上

  • テスト実行時間の大幅短縮(実時間待機 → 瞬時実行)
  • 安定したテスト結果の実現
  • デバッグ作業の効率化

テスト品質の向上

  • 複雑なタイミング処理の正確な検証
  • エッジケースの網羅的なテスト
  • 大規模アプリケーションでの信頼性確保

実践のポイント

段階重要なポイント期待される効果
基礎useFakeTimers() の正しい設定基本的なタイマーテストの実現
応用API の使い分けと適切なクリーンアップ複雑な処理の安全なテスト
発展非同期処理との組み合わせ方法実際のアプリケーションレベルでの活用
最適化パフォーマンス測定とスケーラビリティ大規模プロジェクトでの実用性

今後の展望

タイマーモック技術をマスターすることで、リアルタイム性が要求される Web アプリケーションの品質を飛躍的に向上させることができます。特に、自動保存機能、通知システム、リアルタイム通信などの機能開発において、その威力を発揮するでしょう。

継続的にテスト技術を向上させ、より堅牢で保守性の高いアプリケーションを構築していきましょう。

関連リンク