🧪

【改訂】Vitest + Storybook + MSW + GraphQL でユニットテストの基盤を構築した話

2025/03/22に公開

はじめに

当時、個人開発でStorybook + Jest + MSW + GraphQL のフロントエンドのテスト導入していましたが、めちゃめちゃむずくハマってしまったので、備忘録的に記事を投稿しました。

https://zenn.dev/nappa/articles/1a91a4ad1a71db

あれから、時間が経ち、テスト周りの技術選定の知見もアップデートされて、そろそろブラッシュアップが必要だと思い、改訂版の記事を出しました。

対象読者

  • 開発しているサービスに MSW + Storybook を導入していて、これからテスト導入を検討している開発者

サンプルアプリの全体のアーキテクチャ

今回サンプルとなるアーキテクチャの全体図はこちらです。

image

基本的には、GraphQLを共通のクエリ言語としつつ、モノレポで全体管理している設計にしています。
この環境で、フロントエンドのコンポーネントテストの導入を試みました。やりたいこととしては、

  • Storybookでカタログ化しておきたい
  • Storybook をユニットテストで再利用出来るようにして、連携してテストかけるようにしたい

というのがありました。
そこで、以下の構成でユニットテストの基盤構築することにしました。

ユニットテストの基盤構成

  • UI Catalog: Storybook
  • Test Runner: Vitest
  • Mock: MSW
  • Language: TypeScript

前提条件

フロントエンド環境

  • Next.js (TypeScript)
  • Apollo Client

テストするコンポーネント

以下のコンポーネントをテストします。シンプルにユーザ名を一覧で表示するコンポーネントです。

component/Users/index.tsx
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 で次のように定義します。

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 ファイルを作成します。

Users/index.stories.tsx
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 という名前のユーザが入ります。

mock/index.ts
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 の内容を以下に書き換えます。

.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 の内容を以下に書き換えます。

.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 なのか

https://zenn.dev/knowledgework/articles/jest-to-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 を作成して、以下を記載します。

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 を作成します。

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'),
    },
  },
});

テスト用のレンダリングコンポーネントを作成します。

utils/testRenderer.tsx
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>,
  );
};

次に、テストファイルを作成します。
モックデータが正常に検知できてるか確認するため、敢えて存在しないユーザ名を指定して失敗させるテストケースを作ります。

component/Users/index.spec.tsx
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 にコマンド追加します。

package.json
"scripts": {
  ...
  "storybook": "storybook dev -p 6006",
  "build-storybook": "storybook build",
  "test": "vitest"
}

テストを実行

APIサーバー起動後、テストを実行して、通るか確認します。
最初にテスト失敗するか、確認します。

image

失敗しました。内容見てみると、モックデータが検知されているのがわかります。

今度はテストを修正して、正常に通るか実行します。

component/Users/index.spec.tsx
// ...
describe('Primary', () => {
  test('renders correctly', async () => {
    testRenderer(<Primary />);

    await expect(screen.findByText('Name 1')).resolves.toBeTruthy();
  });
});

image

通りました!

...ただ、これでも動くんですが、ここから、swc でトランスパイルするように変更します。

Vitest を swc でトランスパイルさせる

まずは、unplugin-swc というパッケージをインストールします。

pnpm add -D unplugin-swc

次に vitest.config.mts に以下の変更を入れます。

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 というエラーが発生します。

https://zenn.dev/nbstsh/scraps/97ded6d64561e5

これを防ぐために jsc.transform.react.runtime オプションを automatic にしています。

それでは、再度テスト実行してみましょう。

image

無事に通りました!

まとめ

無事に構築が完了しました。フロントエンドの技術は年々進化しているので、この先どんなアップデートがあるのか、楽しみですね!

GitHub

今回検証に使ったリポジトリ。
https://github.com/beginwrite/beginwrite

Discussion