Chapter 05

非同期カウンタコンポーネントのテスト

Satoshi Takeda
Satoshi Takeda
2021.02.24に更新

非同期カウンタコンポーネント実装

ここで扱うコンポーネントは前章のカウンタコンポーネントよりも少しだけ複雑な UI を持っています。ボタンを押下したタイミングではカウントアップされず、ローディング状態となります。その際に UI はローディング状態を示すテキストが表示されます。同時にボタンは非活性状態となり押下できません。

また delay というコンポーネントの Props(もしくは初期値の 1000)ミリ秒後にコンポーネント内のテキストはカウントアップされ、ローディング状態が解かれるとローディングのテキストは非表示となります。そしてボタンは活性状態となり再度押下可能になります。

そこそこ複雑になってきました。

AsyncCounter.tsx
import { useCallback, useState } from "react"

export function AsyncCounter({ delay = 1000 }: { delay?: number }) {
  const [count, setCount] = useState(0)
  const [isLoading, setLoading] = useState(false)
  const handler = () => {
    setLoading(true)
    setTimeout(() => {
      setCount(count + 1)
      setLoading(false)
    }, delay)
  }
  return (
    <>
      <div>AsyncCount: {count}</div>
      <div>
        {isLoading && <span>...Loading</span>}
        <button onClick={handler} disabled={isLoading}>
          AsyncIncrement
        </button>
      </div>
    </>
  )
}

テストを考え実装する

コンポーネントがやっていることが少し増えてきたので少し整理しましょう。UI が操作されたり変更されたりするポイントは 4 つほどありそうです。

  1. 画面の初期表示
  2. ボタンを押下すると非同期でカウントアップ
  3. ボタンを押下するとローディング UI が表示、非同期のコールバック実行後に非表示
  4. ボタンを押下するとボタンが非活性化、非同期のコールバック実行後に再度活性化

以上のように分解してテストコードに書くことができます。ボタンを押下してからのテストシナリオをすべて同じテストケースで書ききることも可能ですが、整理のしやすさから今回は以上のような整理しています。非同期を扱ったコンポーネントおけるテストの特徴的な部分と React におけるテスト用ヘルパー関数 act の使い方などを見ていきます。

フェイクタイマーで macrotask をハケさせる

AsyncCounter.test.tsx
test("ボタン押下 1 秒後は 1 カウントアップ", () => {
  jest.useFakeTimers() // A.タイマーを Jest が提供するフェイクのものに差し替える
  render(<AsyncCounter />)
  const button = screen.getByText("AsyncIncrement")
  fireEvent.click(button)
  act(() => {
    jest.runAllTimers() // B.フェイクタイマーの macrotask 全てをハケさせる
  })
  screen.getByText("AsyncCount: 1")
  jest.useRealTimers() // C.タイマーを通常に戻す
})

上記はボタン押下後非同期でカウントアップされるテストコードです。実際のテストコードにコメントをつけています。async/await な sleep 関数を用意して実際に 1 秒待つといった書き方もできますが、ここでは Jest のタイマー詐称を利用します(A)。仮に Props の初期値が 10000 ミリ秒だった場合、このテストに 10 秒も待つわけにいきませんから。

B では macrotask をすべてハケさせます。macrotask とは JavaScript におけるイベントループの中でコールバックキューとしてキューイングされるタスクのひとつです。setTimeoutsetInterval のコールバックがそれに該当します。ですのでここでは積まれた setTimeout のコールバックキューをハケさせるということになり、delay で指定された 1000 ミリ秒後コールバックであるカウントアップが実行されるということになります。フェイクしているだけなので実際に 1000 ミリ秒かかるわけではありません。

ここでは利用していませんが任意のタイミングまで進めたいといった場合には jest.advanceTimersByTime(ms: number) も利用可能です。

なおこのテストケースでのみタイマーをフェイクしたかったため最後にタイマーを通常に戻しています。(C)

macrotask/microtask と分類されるコールバックキューですが、実行優先度に違いがあります。microtask は先入れ先出しでタスクの実行はほかになにも実行されていないときにだけ開始されます。

setTimeout(() => console.log("timeout"))
Promise.resolve().then(() => console.log("resolved"))
console.log("sync")
// 上記なら、sync -> resolved -> timeout の順に出力

act 関数

先ほどのテストコードでもう 1 つ特徴的な関数・記述がありました。以下に示した act 関数です。この関数は @testing-library/react からエクスポートされているものの、react-dom/test-utils から提供されたテスト用ヘルパー関数をブリッジしたものです。

act(() => {
  jest.runAllTimers()
})

act がどういったものか説明する前に act 関数引数内で実行された jest.runAllTimers()act の外側で実行してみます。

- act(() => {
   jest.runAllTimers()
- })

テストは正常に終了しますが警告が出るようになってしまいました。このコンポーネントの内部でのアップデートには act 関数でラップする必要がある、テスト時のコンポーネントの状態変更が行われる際には act でラップするように、とあります。親切に React ドキュメントのリンクが表示されるので見に行くのですが、残念ながら少し混乱してしまう恐れがあります。

// Test first render and componentDidMount
act(() => {
  ReactDOM.render(<Counter />, container);
});
// Test second render and componentDidUpdate
act(() => {
  button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});

上記はドキュメントに記載されたコードです。書き味こそ違いますが、render やボタン押下時に必ず act を使ってラップしておりあたかもラップ必須といった具合に見えます。となるとここまで書いてきたクリックによる状態変更にも act 関数が必要だったり、renderact 関数でラップしていないので修正する必要があるのでしょうか。

答えはさらに React テストレシピ集に行って確認ができます。

直接 act() を使うのはちょっと冗長だと感じるかもしれません。ボイラープレートの記述を軽減するために、React Testing Library のようなライブラリを使うこともできます。このライブラリのヘルパは act() でラップされています。

ということです。なるほど、React Testing Library が提供する render 関数を見に行って納得がいきました。すでに act でラップされているのです。

act 関数を使うシーンがそこまで多くなることはあまりないと考えていますが、筆者はタイマーを利用したテストや Apollo が提供しているテスト用コンポーネント MockedProvider がモックレスポンスを提供するまでのタイマー実行のために使っています。

なぜどういったタイミングで必要になるかについては testing-library からリンクされた下記のドキュメントが非常に参考になったのでご一読いただくと良いでしょう。

テストで利用したマッチャーについて

ボタンの活性非活性では下記のようなマッチャーを利用しました。これは @testing-library/jest-dom が拡張する Jest 用のマッチャーの一つです。

expect(button).toBeDisabled()
expect(button).not.toBeDisabled()

またドキュメント内に期待する要素が存在するかどうかのマッチャーもあります。ローディング UI の要素を取得し画面(ドキュメント)に存在するかどうかのマッチャーです。

expect(screen.queryByText("...Loading")).toBeInTheDocument()
expect(screen.queryByText("...Loading")).not.toBeInTheDocument()

以降もこういった形で適宜 testing-library family が提供するヘルパーやマッチャーに頼りながら、ユーザーに何が表示され操作しているかを表現していくことになります。


次章では UI ではなく Hooks を扱います。Jest が提供する API を用いてテストを書いていくことになりますが、マウントされた際にバックグラウンドで実行されることをテストするため、最初の方針とも少し違った色合いになりそうです。コンポーネントのユニットテストから少し距離をおいてみていきましょう。