Storybookを使ったページのビジュアルリグレッションを防ぐテスト(Next.js)

kaori
kaori

UI テストはメンテナンスが大変だったり、実装方法が確立されていなかったりして、なかなか導入が難しかったと思います。しかし、Storybook が改革を起こしてくれました!彼らも提唱しているように、UI テストに関しては Unit テストや E2E などの分類ではなく、ビジュアル、インタラクションなどでテストを分けるとうまくいくと思います!

今回は、フロントエンドにテストがない状態ではじめにいれるといいかなと個人的に思っているページ単位のビジュアルテストを紹介します。 理由はこれです。

  • コンポーネント単位で Storybook が作れる状態ではない可能性がある
  • その場合、リファクタが必要
  • 安全にリファクタするために Page の品質だけは維持しておきたい

Page のビジュアルテストの方法

主に Storybook の Tutorial に沿ってやっていきます。

すでにプロジェクトがあって、Storybook はインストール済みだと仮定します。まだの人は公式のチュートリアルを参照してください。

https://storybook.js.org/docs/get-started/frameworks/nextjs

準備編

Rest なリスクエストをモックするために msw モジュールとアドオンをインストールします。

https://storybook.js.org/addons/msw-storybook-addon

npm i msw msw-storybook-addon -D
npx msw init public/

次に、.storybook/preview.js を編集します。

import type { Preview } from '@storybook/react';
import { initialize, mswLoader } from 'msw-storybook-addon';

// Initialize MSW
initialize();

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
  loaders: [mswLoader],
};

export default preview;

ページ用の Story ファイルを作成します。 そのページで呼び出しているエンドポイントのレスポンスをモックします。

例えば、https://pokeapi.co/api/v2/pokemon?limit=3を Page で呼んでいたと場合、以下のようなモックを作成することができます。

import { http, HttpResponse } from 'msw';

import Home from './page';

const res = {
  results: [{ name: 'bulbasaur' }, { name: 'ivysaur' }, { name: 'venusaur' }],
};

export default {
  component: Home,
  title: 'Home',
};

export const Default = {
  parameters: {
    msw: {
      handlers: [
        http.get('https://pokeapi.co/api/v2/pokemon?limit=3', () => {
          return HttpResponse.json(res);
        }),
      ],
    },
  },
};

これで、ページ単位で Story を表示することができるようになりました。モックでレスポンスの値を変更することで、エラーの場合や、配列が空の場合などにどのような表示になるか試すことができます!

もし E2E でカバーしていたテスト範囲があれば、Story で行うことでとても柔軟に書くことができます! ただし、モックしているので、正常なユーザーフローは e2e で行ってください。

Chromatic を使って、ビジュアルに変化があったことをキャッチする

Chromatic を使用することで、ビジュアルに変更があったことをキャッチすることができます。Storybook を使用するなら併用することをおすすめします!

例えば、フォントの太さを変更した場合、Chromatic 上でどこに変化があったのかの差分を確認することができます。

Chromaticでビジュアルの差分を表示

Chromatic に Publish するコマンドでも発見することができます。

コマンド上でのビジュアルの差分の警告

トラブルシューティング

Server component がレンダリングされない

https://storybook.js.org/blog/build-a-nextjs-app-with-rsc-msw-storybook/

Storybook は v8 以降の場合、main.tsの Config でexperimentalRSCtrueに設定します。

  features: {
    experimentalRSC: true,
  }

Layout が表示されない

各 Story を Layout で囲うかpreview.tsxに decorator を追加します。

import Layout from '../app/layout.tsx';

export default {
  // other settings
  decorators: [
    (Story) => (
      <Layout>
        <Story />
      </Layout>
    ),
  ],
};

invariant expected app router to be mounted とういうエラーがでた

https://storybook.js.org/docs/get-started/frameworks/nextjs#set-nextjsappdirectory-to-true

Pages router 内で、next/navigation を使っている可能性があります。原則として、next/navigation は App router でしか使えません。そのため、stories ファイルに使っている旨を追加します。

  parameters: {
    nextjs: { appDirectory: true },
  }

getServerSideProps を使用している場合

meta の loaders で getServerSideProps の戻り値を取得して、render の loaded から値を取り出して、Page コンポーネントに渡します。 今回の例では、getServerSideProps の引数の context は必要なもののみ渡しています。

node-mocks-http を使用して context を作成する例もあったりします。

const meta: Meta<typeof Page> = {
  render: (args, { loaded: { user } }) => <Page {...args} user={user} />,
  loaders: [
    async () => {
      const context: GetServerSidePropsContext = {
        query: { userId: 'user' },
        req: {} as GetServerSidePropsContext['req'],
        res: {} as GetServerSidePropsContext['res'],
        resolvedUrl: '',
      };
      const data = await getServerSideProps(context);
      return data.props;
    },
  ],
};