フロントエンドのインテグレーションテストを簡単に書けるようにしたいと思ったので、mswを使用してインテグレーションテストを実装してみた。
PR
以下の3回ののPRで実装。
実装①: typed-document-nodeに移行
実装の想定としては、インテグレーションを簡単に書けるようにするために、mswを実装したい。 Mockを手動で保守するのは辛いのでgraphql-codegen-typescript-mock-dataで自動生成したいと考えた。
なので、まずはMockを扱いやすい状態にするために、typescript-react-apollo→typed-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で、かなり簡単にインテグレーションテストが実装できるようになったので満足。