useEffectのタイマー処理、その「魔境」をJestで支配する技術
やあ。Reactの現場でコードを書いていて、「なぜかテストが落ちる」「非同期のタイミングでコンポーネントがアンマウントされてエラーになる」といった怪奇現象に遭遇したことはないかな?
特に`useEffect`内で`setTimeout`や`setInterval`を扱う場合、それは「Reactのレンダリングサイクル」と「ブラウザのタイマーキュー」という、異なる2つの世界を同時に管理する必要があることを意味する。ここを適当に書くと、テストは不安定になり、本番環境ではメモリリークの温床になる。
今日は、シニアエンジニアとして、JestのFake Timersを使ってこの「魔境」をどう攻略するか、実務レベルの知見を共有しようと思う。
—
ブラウザの裏側で何が起きているのか
まず、根本的な事実を整理しよう。`setTimeout`はブラウザのイベントループにおいて、呼び出された瞬間に実行されるわけではない。指定したミリ秒経過後に「タスクキュー」に放り込まれ、メインスレッドが空いた瞬間に実行される。
Reactの`useEffect`は、コンポーネントのレンダー完了後に「非同期」で実行される。つまり、テスト環境において、ReactがDOMを更新するタイミングと、JavaScriptのタイマーが発火するタイミングの「ズレ」が、テストの非決定性(不安定さ)を生む最大の原因だ。
これを解決するのが `jest.useFakeTimers()` だ。これを使うと、JavaScriptの時間を「仮想空間」に閉じ込め、我々の手元で進めたり止めたりできるようになる。
—
実践:タイマーを含むコンポーネントとテストコード
まずは、典型的な「一定時間後にステートを更新する」コンポーネントを見てみよう。
// TimerComponent.jsx
import { useState, useEffect } from ‘react’;
export const TimerComponent = () => {
const [text, setText] = useState(‘待機中…’);
useEffect(() => {
// 3秒後にテキストを更新するタイマー
const timer = setTimeout(() => {
setText(‘完了!’);
}, 3000);
// 【重要】クリーンアップ関数を必ず書く
// これを忘れると、コンポーネントが破棄された後にstate更新が発生し
// Reactから警告を受けることになる
return () => clearTimeout(timer);
}, []);
return
;
};
では、これに対するテストコードだ。重要なのは「時間の進め方」の制御だ。
// TimerComponent.test.jsx
import { render, screen, act } from ‘@testing-library/react’;
import { TimerComponent } from ‘./TimerComponent’;
describe(‘TimerComponentのテスト’, () => {
beforeEach(() => {
// フェイクタイマーを有効化
jest.useFakeTimers();
});
afterEach(() => {
// タイマーを元に戻す(他のテストに影響させないため必須)
jest.useRealTimers();
});
test(‘3秒後にテキストが「完了!」に変わるか’, () => {
render(
// 初期状態の確認
expect(screen.getByText(‘待機中…’)).toBeInTheDocument();
// 時間を3秒進める
// act()で囲むことで、タイマー発火による状態更新をReactに伝える
act(() => {
jest.advanceTimersByTime(3000);
});
// 完了後の状態確認
expect(screen.getByText(‘完了!’)).toBeInTheDocument();
});
});
—
プロが現場で意識する「泥臭い」ポイント
このコードを見て、「`act()` で囲むのが面倒だな」と思ったかもしれない。しかし、ここが一番重要だ。
1. `act()` の役割を理解する:
`act()` は、Reactの更新処理(状態の変化や副作用の実行)をすべて完了させてから次の行に進むための関数だ。これを使わずにタイマーを進めると、Reactのレンダリングより先にテストコードの判定が走ってしまい、「期待したのに変わっていない!」というエラーが多発する。
2. クリーンアップ関数の証明:
テスト内で `unmount()` を呼び出した後に `jest.runAllTimers()` を実行してみるといい。もしクリーンアップ関数(`clearTimeout`)が正しく実装されていれば、エラーは出ないはずだ。もし実装が漏れていれば、コンソールに警告が出る。テストは機能を確認するだけではなく、「仕様の穴」を見つけるデバッグツールでもあるんだ。
3. `runAllTimers()` vs `advanceTimersByTime()`:
- `runAllTimers()`: すべてのタイマーを一気に消化する。短くて良い。
- `advanceTimersByTime()`: 明示的に時間を指定する。例えば「2秒後の状態と3秒後の状態」を細かく検証したい場合は、こちらを使うのが定石だ。
最後に:綺麗なコードは、テストから生まれる
多くのエンジニアが「テストが難しいから副作用を避ける」という選択をしがちだ。だが、副作用を適切に制御し、それをテストで証明できるスキルこそが、ジュニアとシニアを分かつ境界線になる。
タイマー処理は、Webアプリにおける「時間の非同期性」との戦いだ。JestのFake Timersを使いこなすことで、その戦いを有利に進めてほしい。もし詰まったら、まずは「今、タイマーは実行されたか?」「コンポーネントはアンマウントされたか?」の2点に立ち戻るんだ。
良いコードを、良いテストで守っていこう。応援しているよ。

コメント