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 userEventfrom"@testing-library/user-event";function Counter(){const[count, setCount]= useState(0);const increment=()=> setTimeout(()=> setCount((c)=> c +1),0);return(<buttontype="submit"onClick={increment}>{count}</button>);}test("my thing works",async()=>{ render(<Counter />); userEvent.click(screen.getByText("0"));awaitnewPromise((resolve)=>{ setTimeout(resolve,50);});});
最後の await は、Warning が出る前にテストが終了しちゃわないように待ってるだけ。
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});
なるほどー。
じゃ、次。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")); awaitnew 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 () =>{ awaitnew 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
できる何かが存在してないといけないけどね。
フリーレン買おっと。思い出すために前の巻を読んでる途中で寝てしまうんだろうな。おやすみなさい。
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。