皆さん、こんにちは!そして、Reactの世界へようこそ!
伝説のチーフアーキテクトがお送りする、現場のリアルが詰まったReact解説ブログへようこそ。
Reactに触れ始めたばかりのあなたも、テストコードと格闘しているあなたも、きっと「なるほど!」と膝を打つような、とっておきの話を今日はお届けします。
今日のテーマは、Reactのテストで時々顔を出す、ちょっと不思議な存在「`act()`」ユーティリティです。
「`act()`?なんか見たことあるけど、一体何者なんだろう…」そう思ったあなた、大丈夫ですよ。今日はその正体を、美味しい料理を作るように、丁寧に紐解いていきましょう。
Reactの「気まぐれ」と、テストの「困った」
まず、`act()` の話をする前に、Reactが私たちの見えないところでどんな風に動いているのか、少しだけ舞台裏を覗いてみましょう。
Reactは、私たちが書いたコードを「はい、どうぞ!」とすぐに画面に反映するわけではありません。まるで、お店の料理人が注文を受けても、すぐに料理が出てこないのと同じです。
注文を受けて(状態が更新されて)、食材を準備して(コンポーネントが再レンダリングされて)、調理して(副作用が実行されて)、お皿に盛り付けて(DOMが更新されて)、ようやくお客様のテーブルに運ばれる(画面に表示される)――そんな風に、いくつかのステップを踏んでいます。
特に重要なのが、Reactの「非同期性」という性質です。
例えば、あなたがボタンをクリックして、カウンターの数字を増やしたとします。Reactは「あ、数字が増えたな」と認識しますが、すぐに画面を更新するとは限りません。他のたくさんの変更をまとめて、効率的に一度に更新しようとすることがよくあります。これを「バッチ処理」と呼んだりもします。
そして、`useEffect` のような「副作用」も、Reactがレンダリングを終えて、画面が落ち着いてから「じゃあ、この副作用の処理をやろうかな」と、少し遅れて実行されることがあります。
この「少し遅れて」というのが、テストを書くときに私たちを悩ませる原因になるんです。
テストが「あれ?」となる瞬間
あなたは、あるコンポーネントのボタンをクリックするテストを書きました。
そして、「ボタンをクリックしたら、画面に表示されるテキストが変わるはず!」と期待して、テストコードでそのテキストを探します。
しかし、テストを実行すると、期待したテキストが見つからず、「テキストがないよ!」と怒られてしまうことがあります。
「え、でも目で見て動かしたらちゃんと変わるのに…なんでテストだけ動かないの!?」
そう、これがReactの「気まぐれ」が引き起こす「困った」現象です。
テストコードは、まるでせっかちな子供のように、ボタンをクリックした「直後」に結果を確認しようとします。しかし、Reactはまだ画面を更新していなかったり、副作用の処理を終えていなかったりするのです。
結果的に、テストコードが「見に行った」時には、まだ画面は古い情報のまま…という悲しいすれ違いが起こってしまうわけです。
大丈夫ですよ。これはあなたのコードのせいじゃないんです。Reactの仕組みと、テストの実行タイミングのズレが原因なんです。
救世主 `act()` の登場! – まるで「時間の魔法」
そこで登場するのが、今日の主役「`act()`」です!
`act()` は、例えるなら「舞台監督」のような存在です。
舞台の上で俳優さんたち(コンポーネント)がセリフを言ったり(状態更新)、道具を動かしたり(DOM操作)、照明を当てたり(副作用)しますよね。
通常、これらの動きは自然な時間経過の中で行われますが、テストの舞台監督 `act()` は言います。
「よし、今から私が指示する間は、舞台上のすべての動きを一気に、そして完璧に終わらせてくれ!」
そう、`act()` は、Reactに対して「今、私の指示する処理の中で起こるすべての更新や副作用を、完全に同期的に(つまり、待たずに一気に)実行して、画面を確定させてくれ!」とお願いする魔法の言葉なんです。
これを使うことで、テストコードが次に結果を見に行くときには、Reactが起こしたすべての変更が、まるで時間の魔法で一瞬にして完了したかのように、画面に反映されている状態になるわけです。
具体的に `act()` がやってくれることは、大きく分けて次の2つです。
1. 状態の更新を同期的に反映: `useState` などでコンポーネントの状態が変わった結果、再レンダリングされるのをすぐに終わらせます。
2. すべての副作用(`useEffect`など)を実行: `useEffect` の中の処理や、そのクリーンアップ関数まで含めて、一連のライフサイクルを同期的に完了させます。
これによって、テストが「すれ違い」を起こすことなく、確実に最新の画面状態を検証できるようになるんです。
`act()` の正しい使い方 – 実際の舞台裏を見てみよう
では、実際に `act()` をどう使うのか、コードの例を見てみましょう。
まずは、シンプルなカウンターコンポーネントを考えてみます。
// src/components/Counter.jsx
import React, { useState } from ‘react’;
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(prevCount => prevCount + 1);
};
return (
{count}
);
}
export default Counter;
このコンポーネントのテストを書いてみましょう。
`act()` なしだとどうなる?(ダメな例)
もし `act()` を使わずにテストを書くと、こんなことが起こりえます。
(ただし、`@testing-library/react` の `fireEvent` や `userEvent` は内部で `act` を呼んでくれるため、この例では通常問題は発生しません。ここでは「もし自分でReactの更新処理を呼び出す場合」を想定した説明として読んでくださいね。)
// src/components/Counter.test.js (act() なし)
import React from ‘react’;
import { render, screen } from ‘@testing-library/react’;
import Counter from ‘./Counter’;
test(‘カウンターの値が正しく増えるか(act()なしの例)’, () => {
render(
// 現在のカウント値が0であることを確認
const countValue = screen.getByTestId(‘count-value’);
expect(countValue).toHaveTextContent(‘0’);
// Incrementボタンを取得
const incrementButton = screen.getByRole(‘button’, { name: /increment/i });
// ボタンをクリック(Reactの状態更新をトリガー)
incrementButton.click(); // ここでReactの状態が更新されますが、
// 画面への反映は非同期に行われる可能性があります
// ★問題発生ポイント!
// Reactが画面を更新し終えるのを待たずに、すぐにテキストを確認しようとする
// expect(countValue).toHaveTextContent(‘1’); // この時点ではまだ ‘0’ の可能性があり、テストが失敗することがある
// ですが、fireEvent.click() は内部でact()を呼ぶので、通常この例は成功します。
// そのため、act()が本当に必要なケースを次に示します。
});
`fireEvent.click()` のような `testing-library` のユーティリティ関数は、実は賢いことに、内部で `act()` を呼んでくれています。だから上記の例では、`act()` を書かなくてもテストは成功するでしょう。
しかし、もしあなたが `fireEvent` を使わずに、直接コンポーネントの内部関数を呼んだり、あるいは `setTimeout` のような非同期処理を含むテストを書く場合は、自分で `act()` を使う必要が出てきます。
`act()` を使ったテスト(正しい例)
では、`act()` を使って、より確実にテストを行う方法を見てみましょう。
特に、`async/await` と組み合わせることで、非同期処理を伴うReactの更新も同期的にテストできます。
// src/components/Counter.test.js (act() あり)
import React from ‘react’;
import { render, screen, act } from ‘@testing-library/react’; // actをインポートする
import Counter from ‘./Counter’;
test(‘カウンターの値が正しく増えるか(act()ありの例)’, async () => { // 非同期処理を含むので async をつける
render(
// 現在のカウント値が0であることを確認
const countValue = screen.getByTestId(‘count-value’);
expect(countValue).toHaveTextContent(‘0’);
// Incrementボタンを取得
const incrementButton = screen.getByRole(‘button’, { name: /increment/i });
// act()の中に、Reactの状態を更新する処理を記述
// await act() とすることで、act()内の非同期処理が全て完了するのを待つ
await act(async () => {
incrementButton.click(); // ボタンをクリックして、カウントを増やす
// もしここで、setTimeoutなどを使って非同期に状態を更新する処理があれば、
// act()がその完了も待ってくれます。
});
// act()の処理がすべて完了したので、Reactの画面更新も完了しているはず
// 期待されるカウント値が1になっていることを確認
expect(countValue).toHaveTextContent(‘1’);
});
この例では、`await act(async () => { … });` の中に、Reactの状態を更新する操作(`incrementButton.click()`)を入れました。
こうすることで、`act()` はこの中の処理が原因で発生するすべてのReactの更新(再レンダリングや副作用の実行)が完了するまで、テストの実行を待ってくれます。
まるで、舞台監督が「はい、このシーンの演技はすべて終わり!」と合図するまで、次のシーンに進まないのと同じです。
`useEffect` と `act()` の素敵な関係
`act()` は、`useEffect` の実行タイミングも制御してくれます。
例えば、コンポーネントがマウントされたときにAPIからデータを取得し、その結果を表示するようなコンポーネントをテストする場合を考えてみましょう。
// src/components/UserDisplay.jsx
import React, { useState, useEffect } from ‘react’;
function UserDisplay({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// コンポーネントがマウントされたら、ユーザーデータを取得する
setLoading(true);
// 実際にはAPIコールですが、ここではsetTimeoutで擬似的に非同期処理を表現
const timer = setTimeout(() => {
if (userId === 1) {
setUser({ name: ‘Alice’, email: ‘alice@example.com’ });
} else {
setUser({ name: ‘Guest’, email: ‘guest@example.com’ });
}
setLoading(false);
}, 100); // 100ミリ秒後にデータが取得される
return () => clearTimeout(timer); // クリーンアップ関数
}, [userId]);
if (loading) {
return
Loading user data…
;
}
if (!user) {
return
No user found.
;
}
return (
{user.name}
{user.email}
);
}
export default UserDisplay;
この `UserDisplay` コンポーネントは、`useEffect` の中で非同期にデータを取得しています。
これをテストする場合、`act()` が非常に重要になります。
// src/components/UserDisplay.test.js
import React from ‘react’;
import { render, screen, act } from ‘@testing-library/react’;
import UserDisplay from ‘./UserDisplay’;
// Jestのタイマーをモック化して、setTimeoutを制御可能にする
jest.useFakeTimers();
test(‘ユーザーデータが非同期で表示されるか’, async () => {
render(
// 最初は「Loading…」が表示されているはず
expect(screen.getByText(/loading user data/i)).toBeInTheDocument();
// ★ここがポイント!
// useEffect内のsetTimeoutが完了するのを待つために、jest.runAllTimers()を使う
// そして、その結果発生するReactの更新をact()で同期させる!
await act(async () => {
jest.runAllTimers(); // タイマーをすべて実行(setTimeoutを即座に完了させる)
});
// act()が完了したので、useEffect内の処理と、その結果のReactの更新が完了しているはず
// ユーザー名が表示されていることを確認
expect(screen.getByRole(‘heading’, { name: ‘Alice’ })).toBeInTheDocument();
expect(screen.getByText(‘alice@example.com’)).toBeInTheDocument();
expect(screen.queryByText(/loading user data/i)).not.toBeInTheDocument(); // ローディングは消えているはず
});
// テストが終わったらタイマーを元に戻す
afterEach(() => {
jest.useRealTimers();
});
このテストでは、`jest.useFakeTimers()` と `jest.runAllTimers()` を使って `setTimeout` のような非同期処理を制御しています。
そして、`await act(async () => { jest.runAllTimers(); });` の部分が肝です。
`jest.runAllTimers()` を呼ぶことで、擬似的なAPIコール(`setTimeout`)が完了します。この完了によって `setUser` や `setLoading` が呼ばれ、Reactの状態が更新されます。
`act()` は、この一連の状態更新とそれに続く再レンダリング、そして`useEffect`の実行(今回は初回マウント時のみですが、もし依存配列が変わるなどで再実行される場合はそれも)をすべて同期的に完了させてくれます。
そうすることで、`act()` のブロックを抜けた後には、期待通りのユーザーデータが画面に表示されている状態になっている、というわけです。
まるで、舞台監督が「このシーンの準備OK!よし、幕が上がって、演技して、幕が下りるまで一気に頼む!」と指示しているようなものですね。
`act()` が必要な場面、不要な場面 – 賢く使いこなすコツ
さて、ここまで `act()` の使い方を見てきましたが、「いつも `act()` を書かないといけないの?」と疑問に思うかもしれません。安心してください、そんなことはありません。
`act()` が不要な場合(実はたくさん!)
- `@testing-library/react` のユーティリティを使う場合:
- `fireEvent.click()`
- `userEvent.click()`
- `render()`
- `unmount()`
- これらは、内部で賢く `act()` を呼んでくれているので、自分で書く必要はありません。ほとんどのUI操作はこれでカバーされます。
`act()` が必要な場合
自分で `act()` を書く必要があるのは、次のようなケースです。
1. コンポーネントの内部関数を直接呼び出すなど、Reactの状態を更新する処理をテストから直接トリガーする場合:
- 例えば、`wrapper.instance().someMethodThatUpdatesState()` のような、コンポーネントインスタンスのメソッドを呼ぶ場合。(ただし、`@testing-library` はこのような内部実装に依存するテストを推奨しません)
2. `setTimeout` や `Promise` など、非同期処理によってReactの状態が更新されるのを待つ場合:
- 上記の `UserDisplay` の例のように、`useEffect` の中で `setTimeout` や `fetch` のような非同期処理を実行し、その結果で状態が更新されるようなケースです。この場合、非同期処理自体の完了を `await` や `jest.runAllTimers()` で待った後、その結果としてのReactの更新を `act()` でラップして同期させる必要があります。
つまり、`@testing-library/react` を使って、ユーザーの視点からUIを操作するテストを書いている限りは、ほとんどの場面で `act()` を明示的に書く必要はありません。
しかし、非同期処理が絡むテストや、より低レベルなReactの更新をテストしたい場合は、`act()` の出番となります。
もしテスト中に `Warning: An update to %s inside a test was not wrapped in act(…)` のような警告が出たら、「あ、ここでReactの更新処理が非同期的に行われているのに、`act()` で囲んでなかったな」と気づくサインです。
焦らず、落ち着いてメッセージを読み解けば大丈夫です。
まとめ
今日は、Reactのテストで心強い味方となる `act()` ユーティリティについて深く掘り下げてきました。
- Reactの非同期性が、テストで「すれ違い」を起こす原因になる。
- `act()` は、Reactの更新や副作用を同期的に実行させ、画面の状態を確定させる「舞台監督」のような存在。
- `@testing-library/react` のユーティリティ関数(`render`, `fireEvent`, `userEvent` など)は、ほとんどの場合、内部で `act()` を呼んでくれているので、自分で書く必要はない。
- `setTimeout` や `Promise` など、自分で非同期処理を制御し、その結果Reactの状態が更新される場合は、`await act(async () => { / 非同期処理の完了を待つ / });` の形で使うと良い。
Reactのテストは、最初は少し戸惑うことが多いかもしれません。特にReactの非同期な挙動は、慣れるまで時間がかかるものです。
でも、`act()` のようなツールを理解し、適切に使いこなせるようになれば、あなたのテストコードはより堅牢で信頼性の高いものになります。
一歩一歩、着実に理解を深めていきましょう。
Reactの世界は奥深いですが、その分、学べば学ぶほど強力なアプリケーションを開発できるようになります。
また次の機会に、さらに深いReactの知見を共有できることを楽しみにしています!

コメント