Storybook + Jest + MSW + GraphQL でユニットテスト導入する
個人開発でStorybook + Jest + MSW + GraphQL のフロントエンドのテスト導入していましたが、めちゃめちゃむずくハマってしまったので、備忘録的に書きます。
はじめに
アーキテクチャの全体図はこちらです。
基本的には、GraphQLを共通のクエリ言語としつつ、モノレポで全体管理している設計にしています。
この環境で、フロントエンドのコンポーネントテストの導入を試みました。やりたいこととしては、
- Storybookでカタログ化しておきたい
- Storybook を Jest で再利用出来るようにして、連携してテストかけるようにしたい
というのがありました。
そこで、以下の構成でユニットテストの基盤構築することにしました。
ユニットテストの基盤構成
- UI Catalog: Storybook
- Test Runner: Jest
- Mock: MSW
前提条件
フロントエンド環境
- Next.js
- Apollo Client
テストするコンポーネント
以下のコンポーネントをテストします。シンプルにユーザ名を一覧で表示するコンポーネントです。
import React from 'react';
import { useQuery } from '@apollo/client';
import type { IUser } from '@beginwrite/app-graphql-codegen';
import { getUsersQuery } from './gql';
import type { GetUsersQuery } from './gql';
const Users: React.FC = () => {
const { error, data } = useQuery<GetUsersQuery>(getUsersQuery);
if (error) return null;
return (
<div>
<ul>
{data?.users.map((user: Pick<IUser, 'id' | 'name'>) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
};
export default Users;
GraphQL のスキーマは、graphql-codegen 使用して作成しています。
Storybook 導入
Storybook をインストールします。
$ pnpm add -D storybook @storybook/react
$ pnpm storybook init
Stories ファイルを作成します。
import Users from './index';
import type { Meta, StoryObj } from '@storybook/react';
export default {
title: 'Compoments/Pages/Users',
component: Users,
} as Meta<typeof Users>;
export const Primary: StoryObj<typeof Users> = {
};
MSW 導入
GraphQLを使ったコンポーネントをテストするには、モックが必要なので、
MSW と関連パッケージをインストールします。
最新版は v2 ですが、Storybook v7 では MSW v2 は動かないので、v1 の最新版をインストールします。
pnpm add -D msw@1.3.2 msw-storybook-addon @mswjs/data storybook-addon-apollo-client
モックデータを定義します。
Name 1
- Name 20
という名前のユーザが入ります。
import { IUser } from '@beginwrite/app-graphql-codegen';
import { graphql } from 'msw';
import { GetUsersQuery } from '@/components/pages/Users/gql';
import { setupServer } from 'msw/node';
const mock: Pick<IUser, 'id' | 'name'>[] = [];
for (let i = 1; i <= 20; i++) {
const user: Pick<IUser, 'id' | 'name'> = {
id: i.toString(),
name: `Name ${i}`,
};
mock.push(user);
}
export const users = () => {
return graphql.query<GetUsersQuery>('users', (_, res, ctx) => {
return res(
ctx.data({
users: mock,
}),
);
});
};
export const handlers = [users()];
// モックサーバー
export const server = setupServer(...handlers);
.storybook/main.ts
の内容を以下に書き換えます。
import { join, dirname } from 'path';
function getAbsolutePath(value) {
return dirname(require.resolve(join(value, 'package.json')));
}
/** @type { import('@storybook/nextjs').StorybookConfig } */
const config = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
getAbsolutePath('@storybook/addon-links'),
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-onboarding'),
getAbsolutePath('@storybook/addon-interactions'),
getAbsolutePath('storybook-addon-apollo-client'),
],
framework: {
name: getAbsolutePath('@storybook/nextjs'),
options: {},
},
docs: {
autodocs: 'tag',
},
staticDirs: ['./public'],
};
export default config;
.storybook/preview.ts
の内容を以下に書き換えます。
import React from 'react';
import { initialize, mswLoader, mswDecorator } from 'msw-storybook-addon';
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import { handlers } from '../src/mocks';
import type { Preview } from '@storybook/react';
// API を定義
const client = new ApolloClient({
uri: 'http://localhost:8000/graphql',
cache: new InMemoryCache(),
});
initialize();
export const decorators = [
mswDecorator,
(story) => <ApolloProvider client={client}>{story()}</ApolloProvider>,
];
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
loaders: [mswLoader],
msw: {
handlers,
},
},
};
export default preview;
Jest 導入
以下コマンドを実行します。
pnpm add -D jest @storybook/test jest-environment-jsdom @jest/globals ts-jest @testing-library/jest-dom @testing-library/react @testing-library/user-event
jest.setup.ts
を作成して、以下を記載します。
import '@testing-library/jest-dom';
import 'isomorphic-unfetch';
import { server } from '@/mocks/index';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
jest.config.ts
を作成します。
import nextJest from 'next/jest';
import type { Config } from 'jest';
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig: Config = {
moduleDirectories: ['node_modules', '<rootDir>/'],
setupFilesAfterEnv: ['./jest.setup.ts'],
moduleNameMapper: {
'@/(.*)$': '<rootDir>/src/$1',
},
testEnvironment: 'jest-environment-jsdom',
testMatch: ['**/*.spec.ts', '**/*.spec.tsx'],
testPathIgnorePatterns: ['src/tests/e2e/'],
transform: {
'^.+\\.(ts|tsx|js|jsx)$': 'ts-jest',
},
};
export default createJestConfig(customJestConfig);
テスト用のレンダリングコンポーネントを作成します。
import React from 'react';
import {
ApolloClient,
ApolloProvider,
HttpLink,
InMemoryCache,
} from '@apollo/client';
import { render } from '@testing-library/react';
export const testRenderer = (children: React.ReactNode) => {
const link = new HttpLink({
uri: 'http://localhost:8000/graphql',
credentials: 'same-origin',
});
const client = new ApolloClient({
link,
uri: 'http://localhost:8000/graphql',
cache: new InMemoryCache(),
});
return render(
<ApolloProvider client={client}>{children}</ApolloProvider>,
);
};
しかし、この設定だと、テスト実行時にAPI通信できずにエラーが出てしまうので、cross-fetchをインストールします。
pnpm add -D cross-fetch
先ほど作成したコンポーネントに追記します。
import React from 'react';
import {
ApolloClient,
ApolloProvider,
HttpLink,
InMemoryCache,
} from '@apollo/client';
import { render } from '@testing-library/react';
+ import fetch from 'cross-fetch';
export const testRenderer = (children: React.ReactNode) => {
const link = new HttpLink({
uri: 'http://localhost:8000/graphql',
credentials: 'same-origin',
+ fetch,
});
const client = new ApolloClient({
link,
uri: 'http://localhost:8000/graphql',
cache: new InMemoryCache(),
});
const result = render(
<ApolloProvider client={client}>{children}</ApolloProvider>,
);
return result;
};
次に、テストファイルを作成します。
モックデータが正常に検知できてるか確認するため、敢えて存在しないユーザ名を指定して失敗させるテストケースを作ります。
import { describe, test, expect } from '@jest/globals';
import { composeStories } from '@storybook/react';
import * as stories from './index.stories';
import { testRenderer } from '@/utils/testRenderer';
import { screen } from '@testing-library/react';
const { Primary } = composeStories(stories);
describe('Primary', () => {
test('renders correctly', async () => {
testRenderer(<Primary />);
// 敢えて失敗させる
expect(await screen.findByText('Name XXXX')).toBeTruthy();
});
});
コマンド追加
package.json にコマンド追加します。
"scripts": {
...
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "jest"
}
テストを実行
APIサーバー起動後、テストを実行して、通るか確認します。
最初にテスト失敗するか、確認します。
失敗しました。内容見てみると、モックデータが検知されているのがわかります。
今度はテストを修正して、正常に通るか実行します。
// ...
describe('Primary', () => {
test('renders correctly', async () => {
testRenderer(<Primary />);
// 存在するユーザーに指定し直す
expect(await screen.findByText('Name 1')).toBeTruthy();
});
});
通りました!
GitHub
今回検証に使ったリポジトリ。
Discussion