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 アプリケーションの品質を飛躍的に向上させることができます。特に、自動保存機能、通知システム、リアルタイム通信などの機能開発において、その威力を発揮するでしょう。
継続的にテスト技術を向上させ、より堅牢で保守性の高いアプリケーションを構築していきましょう。
関連リンク
- article
【対処法】Cursorで発生する「You've saved $102 on API model usage this month with Pro...」エラーの原因と対応
- article
Vue.js で作るモダンなフォームバリデーション
- article
Jest で setTimeout・setInterval をテストするコツ
- article
Playwright MCP でクロスリージョン同時テストを実現する
- article
Tailwind CSS と Alpine.js で動的 UI を作るベストプラクティス
- article
Storybook を Next.js プロジェクトに最短で導入する方法
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実
- review
え?世界はこんなに良くなってた!『FACTFULNESS』ハンス・ロスリングが暴く 10 の思い込みの正体