Chapter 07

非同期リクエストを扱うコンポーネントのテスト:fetch そのものをモック、実装編

Satoshi Takeda
Satoshi Takeda
2021.02.24に更新

API へのリクエストやらのテストは面倒くさい

もし Apollo を利用しているならテストを書く場合は MockedProvider といったテストユーティリティ向けのコンポーネントを利用できますが、必ず Apollo が選択されるわけではありません。

API リクエストやキャッシュ機能を備えた昨今人気のライブラリ SWRReact Query は Apollo のようなテストユーティリティを持っていません。ライブラリを利用しない場合もユーティリティに準ずるようなモック機構は自分で作ることになることがほとんどです。

ここでは window.fetch というブラウザネイティブの API を利用し Hooks を自作しているケースを取り上げます。本章では window.fetch をモックしテストを完了するといったところをゴールにして読み進めていきましょう。

コンポーネントの実装補足

NativeFetch.tsx
import { usePokemons } from "~/hooks/usePokemons"

export function NativeFetch({ size }: { size: number }) {
  // 内部的に外部 API へ fetch し状態を返すカスタム Hooks
  // 初回マウント時以降、以下のように状態が変化する
  // 初回 loading = true, pokemons = null
  // 2 度目 loading = false, pokemons = [...]
  const [loading, pokemons, error] = usePokemons(size)
  if (error) {
    throw error
  }
  if (loading || pokemons === null) {
    return <p>loading...</p>
  }

  return (
    <>
      {pokemons.length === 0 ? (
        <p>no pokemon</p>
      ) : (
        <>{/** 略 */}</>
      )}
    </>
  )
}

コンポーネントとして簡素にしていますが、内部では usePokemons といったカスタム Hooks が配置されています。Props に指定された size をパラメータにしてポケモン GraphQL API へリクエスト、ローディングなど UI の状態を変更させながらレスポンスを受けた後にリスト形式でポケモンたちを表示する想定です。

テストを考え実装する

usePokemons のカスタム Hooks が非同期リクエストを担当しコンポーネントにまでデータを運びますがここではカスタム Hooks を分離せず、usePokemons を含んだコンポーネント内でテストを完結させましょう。実装詳細ではなく、コンポーネントがマウントされるとユーザーにどう見えるかといった方針に立ち戻りテストを組んでいきます。

  1. ユーザーが画面に来ると先にローディング UI が表示される
  2. API からレスポンスを受けるとリストでポケモンが表示される
  3. API からのレスポンスを受けたがポケモンがいない場合はその旨画面に表示される
  4. API からのレスポンスに異常があった場合例外を送出し画面に表示する(実装は例外を投げていますが多くはどこかの ErrorBoundary などで捕まえることにはなるでしょう)

0. モックのための下準備

さっそく頭からテストしたいところですが、window.fetch を偽装し Response オブジェクトを模倣して Promise として返却するモックを準備します。深く見る必要があれば WHATWG Fetch Standard の仕様も参考にしましょう。

NativeFetch.test.tsx
const pokemons = [/** ...pokemons */]
const dataPokemonsMock = () =>
  new Promise((resolve) => {
    resolve({
      ok: true,
      status: 200,
      json: async () => ({ data: { pokemons } })
    })
  })

この Promise を返却する関数は正常なレスポンスが API から返却されることを期待したモックです。テスト時 window.fetch の代替オブジェクトとして差し替えられる想定です。

先に挙げた Fetch 仕様で定義された Response Class IDL を参考に TypeScript で fetch を表現すると以下のようになるでしょうか。(実際のところ TypeScript は別途 Response, Body を定義しています

declare function fetch(): Promise<{
  ok: boolean
  status: number
  json: () => Promise<Record<string, unknown>>
}>

本来はもっとフィールドが存在するのですが、今回すべてを網羅する必要はありません。実装状況により必要そうなフィールドのみ準備すればよいでしょう。今回だと window.fetch を利用した実装は以下のようになっているためモックとしては十分であると考えます。

export async function fetchPokemons(size: number): Promise<Pokemon[]> {
  const response = await fetch(API_ENDPOINT, {
    /** 略 */
  })
  // Response.json(): Promise<{ data: { pokemons: Pokemon[] } }>
  const { data } = await response.json()
  // Response.ok: boolean
  if (response.ok) {
    return data?.pokemons ?? []
  } else {
    // Response.status: number
    throw new Error(`http status: ${response.status}`)
  }
}

モックの準備は整いました。テストを見ていきましょう。

1. ローディング UI のテスト

Jest が動作する Node.js 上に window.fetch は存在しません。そのためテストケース毎に global.fetch として逐次実装していきます。

NativeFetch.test.tsx
test("render:loding", async () => {
  global.fetch = jest.fn().mockImplementation(dataPokemonsMock)
  // ...
})

テスト開始時 global.fetch へ先に作っておいたモックで実装を適用します。以降 fetch 関数が呼ばれるとどんな引数を受けようがこのモックで代替されます。テストケースごとに上書きしていますが都度モックを変更したいので問題ありません。前提条件のためのセットアップをテストケース間で共有することの方が身の危険を感じます。

ローディングで確認したいのはコンポーネントマウント後にはローディング UI を表示していることです。

NativeFetch.test.tsx
test("render:loding", async () => {
  // ...
  screen.getByText("loading...")
  // ...
})

loading... の Text をもった要素を取得しこの「ユーザーが画面に来ると先にローディング UI が表示される」テストケースは完了します。仮に要素が存在せず取得できない場合は例外が発生しテスト自体も失敗するためここではマッチャーを使った記述をしていません(この辺はどういった構文が分かりやすいかといった観点でチームのルールを決めてもよいでしょう)。

さてこれで 1 つめのテストがパスしましたが、以下の警告が出てしまいます。

「またおまえか」 といった警告ですね。結果から言うとテストケースごとに cleanup で画面からコンポーネントをアンマウントしているにもかかわらず、非同期でリクエストした後の状態変更が発生しているため act で状態変更をしキューを始末せよといったことが状況から類推されます。

ローディング UI のためのテストとは直接関係ありませんが、ライフサイクルを最後まで記述しきったほうが良さそうです。act を使う場合と testing-library を利用する場合がありますが、急なヘルパー関数によるネストで不可解になることを避けるため、リポジトリでは後者を選択しています。

// `act` 関数を利用するケース
// `act` 関数内で Promise.resolve 実行をコールバックにした macrotask をキューイングすることで
// microtask(=ここでは fetch が返す PromiseJob) キューが必ず実行されることを担保している
await act(async () => {
  await new Promise((resolve) => setTimeout(resolve, 0))
})

// testing-libarary が提供する記述でフォローするケース
// `act` は不要でローディング UI が画面からなくなるまでこのテストケースは終了しない
await waitForElementToBeRemoved(() => screen.getByText("loading..."))

この act(...) warning については Kent C. Dodds が言うように テスト完了後に何かが発生しており待機する必要がある と考えてよいでしょう。

If you're still experiencing the act warning, then the most likely reason is something is happening after your test completes for which you should be waiting (like in our earlier examples).

2, 3 API のレスポンスを受けたあとのコンポーネントテスト

ここまで記述した内容からそこまで足し込まずにテストを記述できます。2, 3 では差し替えるモックが違う点はソースコード(正常ゼロ)からご確認ください。

test("render:pokemons", async () => {
  // ... 2 で利用するのは正常なレスポンスを持ったモック
  await waitForElementToBeRemoved(() => screen.getByText("loading..."))
  expect(asFragment()).toMatchSnapshot()
})
test("render:no pokemon", async () => {
  // ... 3 で利用するのはポケモンがゼロのレスポンスを持ったモック
  await waitFor(() => screen.getByText("no pokemon"))
  expect(asFragment()).toMatchSnapshot()
})

2 ではローディング UI が削除されたあとの DOM をスナップショットとし、3 ではポケモンがいない場合の Text を含んだ要素の出現を待っており(要素が出現しなければ例外となりテストは落ちます)最後にスナップショットをとっています。

4. API でエラーが発生しており例外が投げられるテスト

コンポーネントのコードパスには例外を受け付けた場合の所作も記述されています。usePokemons Hooks の実装からもわかるように API が正常な HTTP ステータスコードを返さないケースで例外を送出します。それらを捕捉してここでは画面に表示してテストすることを考えます。

が、実は筆者もコンポーネントが例外を投げる場合のテストをどう書くか悩んだ結果、テスト用の ErrorBoundary を作成して UI に例外から得られるメッセージを表示しているので、もし良策あれば教えていただきたいところです。

さてテスト用の ErrorBoundary とそれをラップしたテストしたいコンポーネントは以下のようになっています。いたってシンプルで、エラーを受け付けた場合はクラスコンポーネントの State に Error オブジェクトを指定し画面にエラーメッセージが表示されるだけのしくみです。

test-utils/ErrorBoundary.tsx
export class ErrorBoundary extends React.Component<unknown, { hasError: boolean; error: Error }> {
  state = { hasError: false, error: new Error() }
  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  render() {
    if (this.state.hasError) {
      return <div data-testid="error">{this.state.error.message}</div>
    }
    return this.props.children
  }
}

これを使ってテストしたいコンポーネントを事前にラップしておきます。この NativeFetch コンポーネントのすべてのテストケースで、ラップしたコンポーネントを利用することにしましょう。

const WrappedNativeFetch = ({ size }: { size: number }) => {
  return (
    <ErrorBoundary>
      <NativeFetch size={size} />
    </ErrorBoundary>
  )
}

さてテストそのものですが、先に説明しておくべき箇所があります。

test("error", async () => {
  // ... 略 
  const spy = jest.spyOn(console, "error")
  spy.mockImplementation(() => void 0)
  // ... 略
  spy.mockRestore()
})

以上のようにテストの前後でスパイを作成し無を実装しており、テスト終了直前に戻しています。これは Jest 実行時に例外が発生した警告を出力してしまい可読性を下げてしまうからです。

テスト自身はここまでやってきたことと変わりはありません。異常を示すモックを global.fetch へ実装し、画面に期待するエラーメッセージが表示されればこのテストケースはパスします。

render(<WrappedNativeFetch size={5} />)
await waitFor(() => screen.getByText("http status: 400")) 

テスト終了時には global.fetch のモックを解除するため(別のテストでモックするときのために前提条件をフラットにしておかなくてはいけません)、ファイルのテスト終了サイクルで以下のような処理をくわえます。

afterAll(() => {
  ;(global.fetch as jest.Mock).mockClear()
})

モックについて考えること

モックは実装さえしてしまえばなんてことはありませんが、window.fetch をモックしたいのはこのコンポーネントだけとは限らないでしょう。モック用テストユーティリティとして関数化を検討し始めることも考えられますし、ひょっとすると window.fetch の呼び出し回数や引数チェックが必要になるシーンがないとも限りません。

たださまざまなテストケースで前提条件が違うコンテキストを考慮しながら共通化したり関数化したりといった作業は骨が折れるものです。リクエストにのった Cookie や Authorization ヘッダが暗黙的に認証の大前提となっている場合、Response オブジェクトについてさらに考える必要がある場合、考慮すべきことはどんどん増えていってしまいます。テストのためのユーティリティが多様なロジックを抱える必要が出てきたら、テスト自身がパスしてもモックが正しいのかという不安のほうが増えていきますよね。

今回はテストコードに一筆で書ききりましたが、よくあるテストファイル構成のように ./fixtures といったディレクトリを切ってモックを格納していても良いでしょう。関心を散らさずコンポーネントごとに逐次モックをテストに合わせて毎度作成するという戦略もあります。

一方で Kent C. Dodds はブログで別のアプローチも示していますMSW といった代替方法の提案です。


次章では MSW を使って window.fetch をモックせずに同じコンポーネントをテストしていきましょう。