【実務・中級編】 useEffect内のタイマー処理のテスト – React実践ガイド

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

{text}

;
};

では、これに対するテストコードだ。重要なのは「時間の進め方」の制御だ。

// 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点に立ち戻るんだ。

良いコードを、良いテストで守っていこう。応援しているよ。

コメント

タイトルとURLをコピーしました