tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

React Apollo MockProvider の知見をまとめた

MockProviderはReact Apolloクライアントのリクエストや結果をモックするのに推奨されている方法です。サンプルコードを見る限りではいい感じに動くように見えますが、実際に使うとなると、結構ハマりどころがあります。MockProviderの使い方のヒントについてはすでにmediumなどに素晴らしい記事がありますが、それでもMockProvierの予期せぬ動作に数時間を溶かしてしまいました。
そんなわけでこの記事では、レスポンスを適切にモックするまでに得られた知見をシェアします。

なおテスト環境は以下です:

@apollo/client: 3.2.0
react-native:0.62.2
jest: 24.9.0
react-native-testing-library: 2.1.1

それでは行ってみましょう!

1. useQueryはhooks packageから直接インポートする

MockProvider で最初にハマったのがこちらのエラーメッセージ。

TypeError: (0 , _client.useQuery) is not a function
    > 35 |   const { data } = useQuery(document, {

これは @apollo/client から useQueryをインポートすると起きます。 @apollo/client/react/hooks/useQuery から直接インポートしましょう。なお gql も同様に import { gql } from '@apollo/client/core'; とする必要があります。

import { useQuery } from '@apollo/client/react/hooks/useQuery'

2. Fragmentを使っているときは__typenameも定義する

上の記事でも触れられているのですが、よく引っかかるので、今一度強調しておきます。Fragment を使用している場合、gql に typename が定義されていなくても、モックされたレスポンスには必ず typename を追加しなければなりません。

例えば以下のようなクエリがあった場合。

query currentUserFriends(
   {
  me {
    id
    friends {
      pageInfo {
        ...pageInfoFragment
      }
      ...
    }
  }
}
fragment pageInfoFragment on PageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

モックデータは以下のようになります。

const mocks = [
      {
        request: {... },
        result: {
          data: {
            me: {
              id: '123',
              friends: {
                __typename: 'FriendConnection',
                pageInfo: {
                  __typename: 'PageInfo',
                  endCursor: '1',
                  hasNextPage: false,
                  hasPreviousPage: false,
                  startCursor: '2',
                }, ...
              },
              __typename: 'Me',
            },
          },
        },
      },
    ];

あともう一つ注意したいのが、Optional なフィールドであってもすべて何かしら定義する必要がある点です。

{|
  __typename: "Friend",
  id: string,
  name: string,
  age: ?number
|}

上のようなフィールドがあったなら、モックデータは以下のように null でもよいので定義します。

{
  __typename: "Friend",
  id: 3,
  name: "Buck",
  age: null
}

3. コンポーネントの描画を必ず走らせる

MockProvider は、明示的にレンダリングをトリガーして更新を行わない限り、モックされたレスポンスを提供しません。react-native-testing-library を使用している場合は、act関数がコンポーネントを更新します。自分は以下のようなラッパーコンポーネントを作成しました。

 const updateWrapper = async (wrapper, time = 10) => {
  await act(async () => {
    await new Promise(res => setTimeout(res, time));
  });
};

テストする際は以下のようになります。

const { getByText } = render(component);
await updateWrapper(component);
expect(getByText('@Buck'));

4. InMemoryCacheを挿入してエラーメッセージをわかりやすくする

クエリのモックが失敗すると以下のようなメッセージがよく表示されます。

No instances found
   96 |     const { getByText } = render(component);
   97 |     await updateWrapper(component);
>  98 |     expect(getByText('@Buck'));

正直言って、このメッセージは全く役に立っていませんね。なぜ失敗しているのかわからない。より正確なエラーメッセージを得るためには、MockProviderのpropsとしてInMemoryCacheを追加してみるとよいです。

<MockedProvider
  mocks={mocks}
  addTypename
  cache={
    new InMemoryCache({
      possibleTypes,
    })
  }
>
  {component}
</MockedProvider>

MockProvider であっても、Apollo は内部的に結果をキャッシュに書き込もうとします。このプロセスのあいだ、InMemoryCache は、クエリで Invariant Violation が見つかった場合にエラーを投げます。

InMemoryCache を追加した後、おそらく以下のようなエラーが表示されると思います。

Invariant Violation: Missing field 'node' in {
     "__typename": "FriendEdge",
     "cursor": "a"
   }

このエラー表示の方がわかりやすいですよね。あとは、エラーに表示されている、不足したフィールドを追加するだけです。

5. "No more mocked responses" error => 引数をチェックする

もうひとつよく見るエラーが"No more mocked responses"です。
f:id:tomoima525:20210221172025p:plain

このエラーが表示された場合、テストで渡されているクエリの変数が実際のコンポーネントと一致していないことが原因です。モックしている変数をよーくチェックする必要があります。

例を挙げてみましょう。以下のコンポーネントには { first:10 } が入力されています。

const FriendList = (props: Props) => {
  const { data } = useQuery(document, { variables: { first: 10 }});

この場合はモックリクエストの variables に同じ引数が渡っているか確認します。

const mocks = [
  {
    request: { query: currentUserFriends, variables: { first: 10 } },
    result: { ... }
  }
]

6. Mutationや複数のQueryをテストする場合はキャッシュを無効化する

キャッシュは Mutation や複数回実行される Query をテストする際に予期せぬ結果を引き起こす可能性があります。それに備えて MockProvider のキャッシュを無効にすることをお勧めします。

<MockedProvider
  mocks={mocks}
  addTypename
  defaultOptions={{
    watchQuery: { fetchPolicy: 'no-cache' },
    query: { fetchPolicy: 'no-cache' },
  }}
  cache={
    new InMemoryCache({
      possibleTypes,
    })
  }
>
  {component}
</MockedProvider>

まとめ

MockProvider は React Apollo テストを書く上で重要なツールですが、思った以上にドキュメントが少ないのが困ったところです。よくわからない問題に遭遇した場合は Github の issue でレポートが上がっているケースもあるので、そこを見に行くこともおすすめです。