Storybook + Jest + MSW + GraphQL でユニットテスト導入する

2024/03/03に公開

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

はじめに

アーキテクチャの全体図はこちらです。

image

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

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

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

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

  • UI Catalog: Storybook
  • Test Runner: Jest
  • Mock: MSW

前提条件

フロントエンド環境

  • Next.js
  • 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 使用して作成しています。

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 と関連パッケージをインストールします。
最新版は 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 という名前のユーザが入ります。

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>('users', (_, res, ctx) => {
    return res(
      ctx.data({
        users: mock,
      }),
    );
  });
};

export const handlers = [users()];

// モックサーバー
export const server = setupServer(...handlers);

.storybook/main.ts の内容を以下に書き換えます。

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

.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 を作成して、以下を記載します。

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

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);

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

utils/testRenderer.tsx
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

先ほど作成したコンポーネントに追記します。

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

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

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

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

テストを実行

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

image

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

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

component/Users/index.spec.tsx
// ...
describe('Primary', () => {
  test('renders correctly', async () => {
    testRenderer(<Primary />);
    // 存在するユーザーに指定し直す
    expect(await screen.findByText('Name 1')).toBeTruthy();
  });
});

image

通りました!

GitHub

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

Discussion