React のコンポーネントのテストを書いてたら、テストは成功してるんだけど、こういう感じの Warning が出力されるって場合がある
Warning: An update to Counter inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): act(() => { /* fire events that update state */ }); /* assert on the output */
ステートを変更するときは act
で囲んでねって書いてある。だから、囲めばいいのかなぁ?って思ってたら、ちょっと触った感じ、どうやらそういうことでもないみたい。ので、うろうろしてたら、この2つの記事にたどり着いた
React の act
API の説明
React Testing Library の作者である Kent C. Dodds の記事
なるほどねぇ
再現コード
例えばこんなコードを書けば再現することができる。この Kent の YouTube 動画を見て書いた → KCD Office Hours 2020-11-19 - YouTube
import { useState } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; function Counter() { const [count, setCount] = useState(0); const increment = () => setTimeout(() => setCount((c) => c + 1), 0); return ( <button type="submit" onClick={increment}> {count} </button> ); } test("my thing works", async () => { render(<Counter />); userEvent.click(screen.getByText("0")); await new Promise((resolve) => { setTimeout(resolve, 50); }); });
最後の await は、Warning が出る前にテストが終了しちゃわないように待ってるだけ。
React の act
API の説明
じゃ、1つ目の記事の方から。React の act
API の説明。
フックを使った処理は同期でレンダリングされるわけじゃなくて、Fiber では非同期でいい感じに処理される。だから、例えばレンダリング時にステートを変更して再描画される場合は、render
の直後にアサーションを書いても、そのステートの変更がまだ反映されてなかったりする。
↓React 16 で React Fiber っていうアーキテクチャで中身を書き直したらしい。へー。あとでゆっくり読もっと。
「だからテスト用に act
を用意したよ!act
の中でイベントを発生させたら、その中でステートの更新とかを終わらせてしまうよ。なので直後にアサーション書いても大丈夫だよ!」ということか。なるほど。
他にも、テストではOKなのに実際に動かしたら最適化のせいでうまく動かないコード、とかも act
で囲むことで、テストの時点で検知できるようになるっぽい。へー。
非同期処理は?
ところで、↑で書いたカウンターの例みたいに、ステートの更新処理自体が非同期処理の場合はどうなるんだろう?というのが、今日のメインの話。
act
の中でユーザーイベントとかを実行しても、その処理の中で Promise を使ってたりすると、act
を抜けたあとでステートの更新が走ってしまう。そうすると冒頭に書いた Warning が出る。分かりにくいなw
ということで、そういう処理を act
の中で呼び出したあとに、例えばこんな風に act
の中で待てば、その間にステートの更新が行われて、想定通りのテストを実行することができる。ということが↑の記事に書いてある。
await act(async () => { await sleep(1100); // wait *just* a little longer than the timeout in the component });
なるほどー。
React Testing Library
じゃ、次。Kent の記事を読もう。
React Testing Library (React Testing Library | Testing Library) は、内部で act
でラッピングしてるから、RTL の関数を使う場合は act
は書かないでいいよ。ってことみたい。
この辺かな? fireEvent
とか render
もラッピングしてある:
configureDTL({ asyncWrapper: async cb => { let result await asyncAct(async () => { result = await cb() }) return result }, eventWrapper: cb => { let result act(() => { result = cb() }) return result }, })
act(() => { if (hydrate) { ReactDOM.hydrate(wrapUiIfNeeded(ui), container) } else { ReactDOM.render(wrapUiIfNeeded(ui), container) } })
あと、便利ツール的な user-event も act
でラッピングされてるみたいね。
はて?ということは?
test("my thing works", async () => { render(<Counter />); userEvent.click(screen.getByText("0")); await new Promise((resolve) => { setTimeout(resolve, 50); }); });
このコードの userEvent.click
は act
でラッピングされているんだけど、その中で呼び出されてるカウンターのインクリメント処理が非同期だから、act
を抜けたあとにカウンターの更新処理が走って、冒頭に書いた Warning が出てるということか。
つまり、この最後の Promise を act
で囲めば Warning は消えそうだな。
test("my thing works", async () => { render(<Counter />); userEvent.click(screen.getByText("0")); await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 50); }); }); });
うん。消えた。
けど嫌だよね?
なんか時間に依存するのは、Flaky Test になるので嫌だー。よね?ということで、RTL の waitFor
という関数を使えば OK。
waitFor
自体は dom-testing-library というライブラリで実装されてる
のだけど、RTL がこれを act
でラッピングしてるのだ。さっき書いたコードの configureDTL
の部分だと思う。
だから、RTL がラッピングしたバージョンの waitFor
を使えば、act
の中で待ってるということになる。ということで、↓のように書けば「カウンターがインクリメントされるまで act
の中で待つ」という意味になるから、Warning も出ないし、Flaky Test にもならないし、平和が訪れた。
test('my thing works', async () => { render(<Counter />); userEvent.click(screen.getByText('0')); await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument()); });
ちなみに、この waitFor
と getByText
的なのを組み合わせた関数として findBy
を提供してくれてるので、こういう風に書く方がきれいかな
test('my thing works', async () => { render(<Counter />); userEvent.click(screen.getByText('0')); expect(await screen.findByText('1')).toBeInTheDocument(); });
すっきりしたー! waitFor
できる何かが存在してないといけないけどね。
フリーレン買おっと。思い出すために前の巻を読んでる途中で寝てしまうんだろうな。おやすみなさい。