【改訂】Vitest + Storybook + MSW + GraphQL でユニットテストの基盤を構築した話
はじめに
当時、個人開発でStorybook + Jest + MSW + GraphQL のフロントエンドのテスト導入していましたが、めちゃめちゃむずくハマってしまったので、備忘録的に記事を投稿しました。
あれから、時間が経ち、テスト周りの技術選定の知見もアップデートされて、そろそろブラッシュアップが必要だと思い、改訂版の記事を出しました。
対象読者
- 開発しているサービスに MSW + Storybook を導入していて、これからテスト導入を検討している開発者
サンプルアプリの全体のアーキテクチャ
今回サンプルとなるアーキテクチャの全体図はこちらです。
基本的には、GraphQLを共通のクエリ言語としつつ、モノレポで全体管理している設計にしています。
この環境で、フロントエンドのコンポーネントテストの導入を試みました。やりたいこととしては、
- Storybookでカタログ化しておきたい
- Storybook をユニットテストで再利用出来るようにして、連携してテストかけるようにしたい
というのがありました。
そこで、以下の構成でユニットテストの基盤構築することにしました。
ユニットテストの基盤構成
- UI Catalog: Storybook
- Test Runner: Vitest
- Mock: MSW
- Language: TypeScript
前提条件
フロントエンド環境
- Next.js (TypeScript)
- 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 使用して作成しています。
GraphQL の Query は gql.ts
で次のように定義します。
import { gql } from '@apollo/client';
import type { IUser } from '@beginwrite/app-graphql-codegen';
export type GetUsersQuery = {
users: Array<Pick<IUser, 'id' | 'name'>>;
};
export const getUsersQuery = gql`
query GetUsersQuery {
users {
id
name
}
}
`;
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 と関連パッケージをインストールします。
pnpm add -D msw msw-storybook-addon 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>('GetUsersQuery', () => {
return HttpResponse.json({
data: {
users: mock,
},
});
});
};
export const handlers = [users()];
// モックサーバー
export const server = setupServer(...handlers);
.storybook/main.ts
の内容を以下に書き換えます。
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
'storybook-addon-apollo-client',
'@storybook/addon-a11y',
],
framework: {
name: '@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;
ユニットテスト構築
ユニットテストは今回 Vitest を採用しました。
なぜ Vitest なのか
上記記事にも書いてあるとおり、Jest は CommonJS 前提で作られるもので、ESM に互換性持たせる場合、トランスパイルの設定が面倒でした。Vitest の場合、ESM に対応しているため、導入がスムーズに行くと考えたためです。また、今回のサンプルアプリは、CommonJS を使うケースがないため、Vitest の方が妥当だと判断しました。
ただし、Next.js は swc トランスパイルで実行しているため、今回は Vitest の方も swc トランスパイルするように設定します。
Vitest 導入
以下コマンドを実行して、必要なパッケージをインストールします。
pnpm add -D vitest @vitejs/plugin-react vite-tsconfig-paths @storybook/test @testing-library/react
vitest.setup.ts
を作成して、以下を記載します。
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '@/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
vitest.config.mts
を作成します。
import path from 'path';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
テスト用のレンダリングコンポーネントを作成します。
import React from 'react';
import {
ApolloClient,
ApolloProvider,
InMemoryCache,
} from '@apollo/client';
import { render } from '@testing-library/react';
export const testRenderer = (children: React.ReactNode) => {
const client = new ApolloClient({
uri: 'http://localhost:8000/graphql',
cache: new InMemoryCache(),
});
return render(
<ApolloProvider client={client}>{children}</ApolloProvider>,
);
};
次に、テストファイルを作成します。
モックデータが正常に検知できてるか確認するため、敢えて存在しないユーザ名を指定して失敗させるテストケースを作ります。
import { composeStories } from '@storybook/react';
import { screen } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import { testRenderer } from '@/utils/testRenderer';
import * as stories from './index.stories';
const { Primary } = composeStories(stories);
describe('Primary', () => {
test('renders correctly', async () => {
testRenderer(<Primary />);
// 敢えて失敗させる
await expect(screen.findByText('Name X')).resolves.toBeTruthy();
});
});
コマンド追加
package.json にコマンド追加します。
"scripts": {
...
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest"
}
テストを実行
APIサーバー起動後、テストを実行して、通るか確認します。
最初にテスト失敗するか、確認します。
失敗しました。内容見てみると、モックデータが検知されているのがわかります。
今度はテストを修正して、正常に通るか実行します。
// ...
describe('Primary', () => {
test('renders correctly', async () => {
testRenderer(<Primary />);
await expect(screen.findByText('Name 1')).resolves.toBeTruthy();
});
});
通りました!
...ただ、これでも動くんですが、ここから、swc でトランスパイルするように変更します。
Vitest を swc でトランスパイルさせる
まずは、unplugin-swc
というパッケージをインストールします。
pnpm add -D unplugin-swc
次に vitest.config.mts
に以下の変更を入れます。
import path from 'path';
import react from '@vitejs/plugin-react';
+ import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
+ swc.vite({
+ module: {
+ type: 'es6',
+ },
+ jsc: {
+ target: 'es2015',
+ parser: {
+ syntax: 'typescript',
+ tsx: true,
+ },
+ transform: {
+ react: {
+ runtime: 'automatic',
+ },
+ },
+ },
+ }),
],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
ここで注目したいのは swc.vite() 内で設定してる jsc.transform.react.runtime
オプションを automatic
にしている点です。
これがない状態でテスト実行すると ReferenceError: React is not defined
というエラーが発生します。
これを防ぐために jsc.transform.react.runtime
オプションを automatic
にしています。
それでは、再度テスト実行してみましょう。
無事に通りました!
まとめ
無事に構築が完了しました。フロントエンドの技術は年々進化しているので、この先どんなアップデートがあるのか、楽しみですね!
GitHub
今回検証に使ったリポジトリ。
Discussion