とろろこんぶろぐ

かけだしR&Dフロントエンジニアの小言

react-testing-library の `act` と `waitFor` を使うべきタイミング

はじめに

react-testing-library を使うと React アプリケーションのコンポーネントの挙動をテストすることができます。

詳しくは公式ドキュメントを読んでください。

https://github.com/testing-library/dom-testing-library testing-library.com

ただ、react-testing-library は若干厄介なところがあり、 state の更新や非同期な処理が含まれる挙動をテストしたい場合に、 React と react-testing-library の挙動を理解していないとテストが思ったように動作しない、という経験をした人もいることと思います。

こういう場合に、 act で処理を囲ったり、 waitFor で処理を囲うことで「なぜか分からないけどテストが動いた」という経験がある人もいるのではないでしょうか。

僕もそうです。

react-testing-library の動きを少しでも理解を深めるために、よく対処方法として上がる actwaitFor について調べたのでまとめておきます。

react-testing-library

react-testing-library 自体には対したコード量はありません。

src ディレクトリ配下: react-testing-library/src at main · testing-library/react-testing-library · GitHub

react-testing-library 自体は、 react を render して HTML DOM を用意したり、イベント発火時に act を囲うようにする程度です。 実際のコンポーネントテストのイベントモック関数自体は testing-library の共通関数 (user-events など) を使うことになると思います。

act の概要

React のコンポーネントをテストしようとした際に、該当の処理コードを act で囲めよという警告が出ることがあります。

    Warning: An update to XXX 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 */

React の act については、警告の中にもある通り、このドキュメントに辿り着きます。

ただこれだけ読んでも分かるようで分かりません。 react-testing-library で act を呼び出しますが、実体は React 本体にあります。

  • react-testing-library の act の実装はここになります。
  • これは、 React (react-dom) のテスト用のユーティリティ(test-utils) の act を呼んでいます。
  • react-domtest-utils の act は React.unstable_act を呼んでいます。
  • 結局 act の本体は、 ReactAct というコードで、 React 本体が用意してくれているロジックになります。

元々出ていた警告も、 react-testing-library などの testing-library 系のコードが出力しているわけではなく、 React 本体のコードが出力しています。

act については、この記事や記事内でも参照されている Example を読むと概要が理解できます。

簡単にまとめると以下だと理解しました。

  • React の状態(ステート)の更新があった際に仮想 DOM への反映が同期的に行われても、実 DOM へのレンダリングコミットは非同期的に行われている。
  • 非同期で行われる全ての実 DOM のレンダリングコミットの完了を待たずに expect してしまうと、意図しない実 DOM の UI 状態でテストをしてしまう。
  • act メソッドで該当のレンダリング処理が走るコールバックを囲うことで、コールバック内での実 DOM 反映のレンダリングコミット処理が全て完了するのを待つ。
  • これにより、 テストするための実 DOM の UI 状態が意図したものになり、正しく expect を実施することができる。

act の概要図

act の内部実装

さらに実際の act の内部の動きは、この記事に詳しく解説されています。

act 関数ではおおよそ以下の動きをしているようです。

  • ReactCurrentActQueue というキューを用意しておく。
  • 与えられたコールバックを実行してレンダリング処理をした際に発生した状態更新による Fiber の UI 更新処理を ReactCurrentActQueue にキューイングする。
  • (ここから正確に読み解けなかったけど) スケジューリングされた UI の更新時に、キューイングしておいたものを flushActQueue 関数で全て実行する。
  • ↑で flush し終えたら act を resolve する。

こういう動きが内部的に行われているので、 act によって状態の整合性が正しく保たれることが(なんとなくですが)理解できました。

ちなみに、冒頭の warning が表示されている箇所では ReactCurrentActQueue.currentnull である(つまりキューが初期化されていない)ことを確認しているようです。

Fiber の UI 更新処理が実施されるタイミングで、キューが空ならおかしいってことなんでしょうか(わからん)。 React-testing-library のテストの時はわかったんですが、普通に npm run dev の時も内部的に act されているってことですか?詳しい人教えてください。

user-event は act を呼んでいる

react-testing-library のコンポーネントテストを実現する際には、testing-libraryuser-event を使ってユーザーの操作イベントをモックすることがあります。

軽く調査すると user-eventact で囲われているので自前で囲う必要はない、といった内容の記述が見られます。

これを具体的にコードで追うと、

user-eventdispatchEvent で呼び出される wrapEvent@testing-library/dom の設定値である eventWrapper を呼び出します。

その eventWrapper という設定値を react-testing-library で定義しています。

ここで指定されたコールバックを act で囲うようになっています。

これによって userEvent.click を何も気にせずに使ったとしても、 act で囲われた状態で利用することができています。

ステートが非同期で更新されるケースには act では対応できない

userEvent.click が act で囲われているにも関わらず、 act で囲えという警告が出ることがあります。

これは大抵の場合、ステートが非同期に更新されてしまっていることによるものだと考えられます。

例えば、

  • ユーザーの操作では API 呼び出しだけが行われる
  • 非同期的に返却された API のレスポンスによってステートが変更される
  • レスポンスデータに準じた UI に画面が更新される

ステートが非同期に更新されるケースの例

上記のように、レンダリングコミットではなくステートの更新自体が非同期的に行われてしまうと、 act では対応しきれないようです。

これを対処するためには、 act で sleep 処理を挟むという手が取れそうです。

await act(async () => {
  await sleep(1100); // wait *just* a little longer than the timeout in the component
});

react-act-examples/sync.md at master · threepointone/react-act-examples · GitHub

これによって act で挟んでいる sleep 中に API のレスポンスが返ってきて UI が更新されれば、 expect が正しく動くことにはなりそうです。

waitFor を使う

しかし act で sleep を囲うくらいなら、 waitFor を使うという手が取れそうです。

waitFor のコードは以下です。

dom-testing-library/src/wait-for.js at main · testing-library/dom-testing-library · GitHub

デフォルトの設定では、 1秒間の間 50 ms ごとに expect を繰り返すことで DOM 反映を待ってくれます。

これを挟み込んでおけば非同期的なステート更新にもおおよそ耐えうることになりそうですね。

ただ、本来的には時間に左右されるような処理にはさせたくないですが、ある程度仕方のないことかもしれません。

findBy 系のメソッドは waitFor も実行している

ちなみに findByRole のような findBy 系のメソッドは waitFor を呼んでいます。

Async Methods | Testing Library

これによって非同期的な UI 更新がなされる場合、 getByRole では動かなかったテストコードが findByRole では動くということが起きます。

おわりに

react-testing-library の actwaitFor 、またそれに関わるメソッドなどについて調査しました。

  • act: React が提供する実 DOM へのレンダリングコミットを待ってくれる仕組み
  • waitFor: 1秒間の間にたくさん expect しながら待ってくれる仕組み

また分からなくなったときに気が向いたら追調査してみようと思います。