フロントエンドのインテグレーションテストを簡単に書けるようにしたいと思ったので、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で、かなり簡単にインテグレーションテストが実装できるようになったので満足。