wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

msw + graphql-codegen-typescript-mock-data + testing-library/react-nativeでインテグレーションテストを実装する

フロントエンドのインテグレーションテストを簡単に書けるようにしたいと思ったので、mswを使用してインテグレーションテストを実装してみた。

PR

以下の3回ののPRで実装。

github.com

github.com

github.com

実装①: typed-document-nodeに移行

実装の想定としては、インテグレーションを簡単に書けるようにするために、mswを実装したい。 Mockを手動で保守するのは辛いのでgraphql-codegen-typescript-mock-dataで自動生成したいと考えた。

なので、まずはMockを扱いやすい状態にするために、typescript-react-apollotyped-document-node

typed-document-nodeは、Apollo Clientなどの特定のライブラリに依存せずにGraphQLのtypeを自動生成してくれる。 使い方は以下のようになる。

typescript-react-apolloでは自動生成したコードを以下のように参照して使用していたが、

■ Before

import {  useItemQuery } from 'queries/api/index'; // graphql-codegenで生成したファイル

  const { loading, data, error, refetch } = useItemQuery({
    variables: {
      id: props.itemID,
    },
  });

typed-document-nodeでは以下のようなコードになり、直接GraphQLのクライントから参照が可能になった。

■ After

import {  ItemDocument } from 'queries/api/index'; // graphql-codegenで生成したファイル
import { useQuery } from '@apollo/client';

  const { loading, data, error, refetch } = useQuery(ItemDocument, {
    variables: {
      id: props.itemID,
    },
  });

まずは、mockをしやすい状態になった。

実装②: graphql-codegen-typescript-mock-dataでmockを自動生成してmswに設定

次は、graphql-codegen-typescript-mock-dataを実装。

コードは以下の通り

■ codegen.yml

  ./src/queries/api/mocks.ts:  // ←追加
    hooks:
      afterOneFileWrite:
        - yarn prettier --write ./src/queries/api/mocks.ts
    plugins:
      - typescript-mock-data:
          typesFile: "./index.ts"
          terminateCircularRelationships: true
          scalars:
            Time: moment

上記を追加してgraphql-codegenを実行するとダミーデータが自動生成されて、以下のように参照が可能。

import { anItem } from 'queries/api/mocks';  // graphql-codegenで生成したファイル

anItem() // queryのItemのダミーデータが設定されている

なので、このダミーデータをmswに設定

■ src/mocks/handler.ts

import { graphql } from 'msw';
import { ItemDocument } from 'queries/api/index';
import { anItem } from 'queries/api/mocks';

export const handlers = [
  graphql.query(ItemDocument, (req, res, ctx) => {
    return res(
      ctx.data({
        item: {
          ...anItem(),
          id: req.variables.id,
        },
      })
    );
  }),
];

以下のコードでテスト用のapolloのクライントを用意

■ src/lib/testUtil.tsx

import React from 'react';
import { GraphQLHandler, GraphQLRequest } from 'msw';
import {
  ApolloClient,
  ApolloProvider,
  createHttpLink,
  InMemoryCache,
} from '@apollo/client';
import { render } from '@testing-library/react-native';
import { server } from 'mocks/server';
import fetch from 'cross-fetch';

const link = createHttpLink({
  uri: 'http://localhost:8080/query',
  fetch,
  credentials: 'same-origin',
});

const client = new ApolloClient({
  link,
  uri: 'http://localhost:8080/query',
  cache: new InMemoryCache(),
});

export const testRenderer =
  (children: React.ReactNode) =>
  (responseOverride?: GraphQLHandler<GraphQLRequest<never>>) => {
    if (responseOverride) {
      server.use(responseOverride);
    }
    return render(<ApolloProvider client={client}>{children}</ApolloProvider>);
  };

上記を利用してテストコードを書くと以下の通りになった。

■ src/components/pages/ItemDetail/__tests__/Connected.test.tsx

import React from 'react';
import { graphql } from 'msw';
import { ItemDocument } from 'queries/api/index';
import { item } from '__mockData__/item';
import * as Recoil from 'recoil';
import * as useHomeItems from 'hooks/useHomeItems';
import { testRenderer } from 'lib/testUtil';
import { screen, waitFor } from '@testing-library/react-native';
import Connected, { Props } from '../Connected';

const propsData = (props?: Partial<Props>): Props => ({
  itemID: 'test',
  date: '2020-01-01',
  ...props,
});

describe('components/pages/ItemDetail/Connected.tsx', () => {
  beforeEach(() => {
    jest
      .spyOn(Recoil, 'useSetRecoilState')
      .mockImplementation((): any => jest.fn());
  });

  it('各項目が正しく表示される', async () => {
    const renderPage = testRenderer(
      <Connected
        {...propsData({
          itemID: 'test2',
        })}
      />
    );

    const queryInterceptor = jest.fn();

    renderPage(
      graphql.query(ItemDocument, (req, res, ctx) => {
        queryInterceptor(req.variables);

        return res(
          ctx.data({
            item: {
              ...item(),
              id: req.variables.id,
              date: '2021-01-01T00:00:00+09:00',
              title: '宝くじが当たった',
              categoryID: 9,
              like: true,
            },
          })
        );
      })
    );

    await waitFor(async () => {
      expect(queryInterceptor).toHaveBeenCalledTimes(1);
      expect(screen.getByText('宝くじが当たった')).toBeTruthy();
      expect(screen.getByText('2020.01.01 / 水')).toBeTruthy();
      expect(screen.getByTestId('like')).toBeTruthy();
      expect(screen.getByTestId('category_id_9')).toBeTruthy();
    });
  });
});

これでテストしたいGraphQLのResponseだけ引数で変更して、それ以外はgraphql-codegen-typescript-mock-dataを使用して自動生成されたダミーデータを返すようにして最小限のmockでテストを実装できた。

実装③: インテグレーションテストを実装する

これで本題のインテグレーションテストを実装。 実際の操作した際に期待する動作をテストするコードを以下の通り作成。

■ src/components/pages/ItemDetail/__tests__/Connected.test.tsx

  it('アイテムを更新する', async () => {
    const renderPage = testRenderer(
      <Connected
        {...propsData({
          itemID: 'test1',
        })}
      />
    );

    const mutationInterceptor = jest.fn();

    renderPage(
      graphql.mutation(UpdateItemDocument, (req, res, ctx) => {
        mutationInterceptor(req.variables);

        return res(
          ctx.data({
            updateItem: {
              id: req.variables.input.id,
            },
          })
        );
      })
    );

    await waitFor(async () => {
      fireEvent.press(screen.getByTestId('menu'));
      expect(screen.getByTestId('menu_modal').props.visible).toBeTruthy();
      fireEvent.press(screen.getByTestId('edit'));
      expect(screen.getByTestId('add_item_modal').props.visible).toBeTruthy();

      fireEvent.changeText(
        screen.getByPlaceholderText('今日何やった?'),
        'コップを割った'
      );
      fireEvent.press(screen.getByTestId('input_category_id_1'));
      fireEvent.press(screen.getByTestId('input_dislike'));
      fireEvent.press(screen.getByText('入力'));

      expect(mutationInterceptor).toHaveBeenCalledWith({
        input: {
          id: 'test1',
          title: 'コップを割った',
          categoryID: 1,
          date: '2020-01-01T00:00:00+09:00',
          like: false,
          dislike: true,
        },
      });
    });
  });

テスト内容は以下の通り

  • アイテムのメニューをタッチ
  • メニューからアイテムの更新をタッチ
  • アイテム更新のモーダルが表示される
  • アイテムのタイトルを入力
  • アイテムのカテゴリーを選択
  • アイテムをdislikeする
  • 入力ボタンをタップ
  • 更新のAPIのRequestパラメータが入力した値と一致しているかテスト

これで、インテグレーションテストの実装ができた。

msw + graphql-codegen-typescript-mock-data + testing-library/react-nativeで、かなり簡単にインテグレーションテストが実装できるようになったので満足。