React のテストを書いてたら act で囲んでよーって言われたとき

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 っていうアーキテクチャで中身を書き直したらしい。へー。あとでゆっくり読もっと。

ReactはなぜFiberで書き直されたのか?Reactの課題と将来像を探る | HTML5Experts.jp

「だからテスト用に 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 もラッピングしてある:

https://github.com/testing-library/react-testing-library/blob/0db811283819fdc9774e36155ff806f44500533c/src/pure.js#L11-L26

configureDTL({
  asyncWrapper: async cb => {
    let result
    await asyncAct(async () => {
      result = await cb()
    })
    return result
  },
  eventWrapper: cb => {
    let result
    act(() => {
      result = cb()
    })
    return result
  },
})

https://github.com/testing-library/react-testing-library/blob/0db811283819fdc9774e36155ff806f44500533c/src/pure.js#L59-L65

  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.clickact でラッピングされているんだけど、その中で呼び出されてるカウンターのインクリメント処理が非同期だから、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());
});

ちなみに、この waitForgetByText 的なのを組み合わせた関数として findBy を提供してくれてるので、こういう風に書く方がきれいかな

test('my thing works', async () => {
  render(<Counter />);
  userEvent.click(screen.getByText('0'));
  expect(await screen.findByText('1')).toBeInTheDocument();
});

すっきりしたー! waitFor できる何かが存在してないといけないけどね。

フリーレン買おっと。思い出すために前の巻を読んでる途中で寝てしまうんだろうな。おやすみなさい。