【Storybook + Chromatic】UI コンポーネントカタログの作成と、Visual Regression Testingの導入


# はじめに

フロントエンドテストを導入したく、色々調査していたところ、Storybook と Chromatic が要件に合いそうだったので、試してみました。

## 前提

記事内で登場する主な用語・ツールについて、簡単に説明します。

### Visual Regression Testing

Visual Regression Testing は、UI の変更を検出するテスト手法です。  
一般的には、UI の変更前後のスクリーンショットを撮影し、差異を検出します。ツールなどを導入することで、自動で差異を検出・可視化できるため、目視で確認するよりも、より効率的、且つ、高精度にリグレッションを検出できます。

UI の更新が頻繁に行われる場合や、リファクタリング（UI を変えずに内部実装を変更）といった場合に、特に有効的です。

### インタラクションテスト

インタラクションテストは、クリックや入力などのユーザー操作をシミュレートし、操作の結果を検証するテスト手法です。  
予めユーザー操作を定義しておくことで、テスト実行時に、テストコードに従ったユーザー操作が自動で行われ、その結果を検証できるため、手動で操作するよりも、より効率的にテストできるようになります。

※ インタラクションテストは、関数がどのように呼び出されるかをモックを使って検証するテスト手法とも言われますが、本記事においては、前述したユーザー操作をシミュレートするテスト手法を指します。

### Storybook

Storybook は、UI コンポーネントのカタログを作成するツールです。  
コンポーネントのデザインや動きをカタログとして可視化したり、インタラクションテストもできます。実際のアプリケーションを起動する必要がないため、ステークホルダー（開発者以外）も利用しやすく、UI のレビューにも活用できます。

また、カタログを作成する基本的な流れとして、Story と呼ばれるコンポーネントの状態を定義したものを作成し、その定義に従って、Storybook が Story をレンダリングすることで、コンポーネントがカタログ化されます。  
コンポーネント単位で Story を作成するため、コンポーネントのテスタビリティの向上や、再利用性の向上にも繋がります。

### Chromatic

Chromatic は、Visual Regression Testing や UI レビューができるツールです。    
Storybook をベースとしたツールであり、Storybook で作成した Story をキャプチャし、差異を検出します。同時に、Storybook で作成したインタラクションテストも実行されます。  
Chromatic 上で、Story の差異に対するコメントやディスカッション、承認プロセスを組み込むことができるため、UI レビューにも活用できます。

# 本記事のトピック

- 導入の背景と選定理由
- UI コンポーネントカタログの作成（今回は、モックを使った API 通信、ページ遷移など、より実際のアプリケーションに近いカタログを作成します）
- インタラクションテスト
- Visual Regression Testing
- CI にテストを組み込む

# 導入の背景と選定理由

## 導入の背景

- UI を変更することが多い、且つ、ページ数やコンポーネント数も多いので、意図しない UI 変更が発生していないか（リグレッション）を検出したい
- 変更を加える度に、UI の差異を目視で確認するのは、手間が掛かるし、ミスも発生しやすいので、検出を自動化し、ぱっと見で差異を確認できるようにしたい
- ついでに、コンポーネントの再利用性も高めたい。同じようなコンポーネントが各所に複数存在することによる、デザインの一貫性欠如やメンテナンスコスト（変更時の影響範囲など）の増加を防ぎたい
- テストのためだけにテストコードを書くのは少し億劫。一石二鳥的なことができれば尚良い

## 選定理由

前述の背景を踏まえ、Storybook と Chromatic を選択しました。

> UI を変更することが多い、且つ、ページ数やコンポーネント数も多いので、意図しない UI 変更が発生していないか（リグレッション）を検出したい

- Chromatic は、キャプチャによる UI の差異を検出できる = Visual Regression Testing
- Storybook は、ユーザー操作をシミュレートし、操作の結果を検証できる（例：フォーム入力によるバリデーションエラーが表示されるかなど） = インタラクションテスト

> 変更を加える度に、UI の差異を目視で確認するのは、手間が掛かるし、ミスも発生しやすいので、検出を自動化し、ぱっと見で差異を確認できるようにしたい

- Chromatic は、変更前後の UI の差異を自動で検出し、差異を可視化できる
- また、Storybook 側で実装するインタラクションテストも、Chromatic のテストプロセスに組み込まれる
- これらの一連のテストプロセスを CI に組み込むことができる

> ついでに、コンポーネントの再利用性も高めたい。同じようなコンポーネントが各所に複数存在することによる、デザインの一貫性欠如やメンテナンスコスト（変更時の影響範囲など）の増加を防ぎたい

- カタログ化により、コンポーネントの検索性が向上し、コンポーネントの再利用性を高めたり、結果として、デザインの一貫性維持にも繋がる
- カタログにするとなれば、自ずとコンポーネントを意識した開発になるため、コンポーネントが整備され、ソースコードの可読性の向上にも繋がりそう

> テストのためだけにテストコードを書くのは少し億劫。一石二鳥的なことができれば尚良い

テストもそれなりに運用・メンテナンスコストが掛かるので、実装する価値を感じないと、メンテナンスが行き届かず廃れてしまう可能性があるかと思います。それを防ぐために、運用コスト削減の仕組み作りはもちろん、テスト以外の部分でも価値を感じると、モチベーションが維持されそう

- コンポーネントカタログとしても活用できる
- カタログ化によるステークホルダーとのコミュニケーション効率化（しやすくなる）
- Chromatic は、レビュー機能を備えており、差異に対するコメントやディスカッション、承認プロセスを組み込むことができる（GitHub Pull Request のようなイメージ）ので、UI レビューにも活用可能

ちなみに、VRT であれば、他の OSS ツールでも実現できますが、セットアップなど非本質的な部分に時間を掛けたくなかったので、セットアップが簡単そうでマネージドな Chromatic を選択。

では、実装していきます。以下、動作環境です。

- Yarn 4.0.2
- Vite 5.0.8
- React 18.2.0
- TypeScript 5.2.2
- Storybook 7.6.7

ディレクトリ構成は、基本的には Vite + React + TypeScript の構成になっています。

# UI コンポーネントカタログの作成

Storybook を使って、UI コンポーネントカタログを作成します。  
より実際のアプリケーションに近いカタログを作成するため、インタラクティブな部分やモックを使った API 通信、ページ遷移なども再現していきます。

## Storybook のセットアップ

Storybook をインストールします。

```bash
$ yarn dlx storybook@latest init
```

インストールが完了すると、プロジェクトルートには `.storybook` ディレクトリが追加されます。`.storybook` には、プロジェクトの設定を記述する `main.ts` と、Story のレンダリングを制御する `preview.ts` があります。  
src には `stories` ディレクトリが追加され、Story のボイラープレートが生成されます。

`main.ts` は以下の様になっていると思います。

```typescript
// .storybook/main.ts

import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-onboarding",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook/react-vite",
    options: {},
  },
  docs: {
    autodocs: "tag",
  },
};
export default config;
```

Story の場所の指定や Addon の設定などを記述します。  
Addon は、Storybook の機能を拡張するためのプラグインです。Storybook には、公式の Addon 以外にも、コミュニティが作成した Addon が多数存在します（参考：[Integrations](https://storybook.js.org/integrations)）。これらを利用することで、Routing 機能や Viewport 機能（レスポンシブデザイン）、Figma との連携が可能になり、よりリッチな UI コンポーネントカタログを作成することができます。

`preview.ts` は以下の様になっていると思います。

```typescript
// .storybook/preview.ts

import type { Preview } from "@storybook/react";

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;
```

パラメーターの設定により、Story のレンダリングを制御したり、Storybook 画面のレイアウトや背景色などをカスタマイズできます。  
`preview.ts` はグローバルに適用されます。Story 単位でカスタマイズも可能です。

## Story の作成

今回は、シンプルなフォームのコンポーネントを例にします。  
テキスト入力と送信ボタンがあり、送信ボタンをクリックすると、入力値が送信されたり、不正値入力時にはエラーが表示され、送信ボタンが非活性になるフォームです。

```tsx
// コンポーネントの例

type FormProps = {
  disabled: boolean;
  value: string;
  error: string;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
  onSubmit: () => void;
};

const Form: FC<FormProps> = ({
  disabled,
  value,
  error,
  onChange,
  onSubmit,
}) => (
  <form>
    <input type="text" value={value} onChange={onChange} />
    <div style={{ color: "red" }}>{error}</div>
    <button disabled={disabled} type="submit" onClick={onSubmit}>
      Submit
    </button>
  </form>
);

export default Form;
```

上記のコンポーネントを Story にします。今回は、正常な状態のコンポーネントと、バリデーションエラー状態のコンポーネントの 2 種類の Story を作成してみます。

Story は、`*.stories.tsx` ファイルに記述します。`*.stories.tsx` は、コンポーネントのメタデータと Story で構成されています。Component Story Format（CSF）と呼ばれる ES Modules ベースのフォーマットで記述し、メタデータや Story を export することで、Storybook が認識してくれます。

```tsx
// Form.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";
import Form, { FormProps } from "path/to/component";

// メタデータ
const meta: Meta<typeof Form> = {
  title: "Components/Form",
  component: Form,
};

export default meta;
type Story = StoryObj<typeof meta>;

const defaultArgs: FormProps = {
  disabled: false,
  value: "",
  error: "",
  onChange: () => {},
  onSubmit: () => {},
};

// Story
export const Default: Story = {
  args: defaultArgs,
};

export const Error: Story = {
  args: {
    ...Default.args,
    error: "Error occurred",
    disabled: true,
  },
};
```

メタデータは Default export、Story は Named exports で記述します。

メタデータの `title` は、Storybook のナビゲーションに表示されるコンポーネントのタイトルです。`/`（スラッシュ）で区切ることで、階層化することができます。  
`component` は、Story のターゲットコンポーネントを指定します。その他にも、`argTypes` や `parameters`、`tags` など、Storybook の機能を拡張するためのパラメーターを指定することもできます。

Story は、名前の部分（上記コードの場合、`Default` と `Error`）が Story のタイトルになります。  
`args` は、Story のレンダリングに必要なもの（コンポーネントの props）を指定します。  
`onChange` などイベントメソッドが空の関数になっていることについては次の章で触れます。

現在の状態を Storybook の画面で確認すると、以下のように表示されます。

![storybook-chromatic-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/77a111f9-6145-86fa-9cbc-e1003187abbc.png)

![storybook-chromatic-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/bd634f77-4cf2-8d5c-d543-f8dd8862db0d.png)

2 種類の Story が表示されていることが確認できます。

Storybook の機能についても少し触れると、  
画面左側にはコンポーネントのナビゲーションがあります。メタデータの `title` がフォルダとなり、配下に Story が表示されます。メタデータの `title` は、ファイル間で同じ値を設定できるため、ファイルを横断した Story のグルーピングも可能です。

画面中央から下の Controls パネルでは、コンポーネントの props を動的に操作できます。props の値によって表示が変わる場合など、コンポーネントの動作を確認するのに便利です。

画面上部にはツールバーがあり、ズームやグリッド表示など、Storybook 標準機能を利用できたり、Addon によるカスタマイズも可能です。

## インタラクティブな Story の作成

前章では、静的な Story を作成しましたが、実際のアプリケーションでは、クリックや入力、ページ遷移や API 通信、Store への接続などインタラクティブな処理が想定されます。  
本章では、より実際のアプリケーションに近い動きを再現するために、インタラクティブな Story を作成していきます。

### クリックや入力などのイベントハンドラー

前章では、`onChange` の部分は空の関数になっていました。これは、Storybook 上でフォームの値を更新するために、イベントハンドラーのモックを作成する必要があるためです。ちなみに、Storybook 上で入力を試みても、フォームの値は更新されません。  

本章では、イベントハンドラーのモックを作成し、フォームの値を更新できるようにします。  
前章で使ったフォームコンポーネントの Story を拡張します。  

```tsx
import { useArgs } from "@storybook/preview-api"; // 追加

export const Default: Story = {
  ...,
  // ↓ 追加
  render: function Render(args) {
    const [{ value }, updateArgs] = useArgs();
    const onChange = (e: ChangeEvent) => {
      const newValue = (e.target as HTMLInputElement).value;
      updateArgs({ value: newValue });
    };
    return <Form {...args} onChange={onChange} value={value} />;
  },
};
```
  
`render` は、Story をレンダリングする際に呼び出され、レンダリングを制御できます。  
`useArgs` は、Story に渡した `args` を取得・更新するための Storybook API フックです。

これでフォームの値を更新できるようになります。

![storybook-chromatic-20.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/38cc4beb-7d8a-280f-c1c2-3c256924127f.gif)

上記は、公式に記載されている方法ですが、モックの部分はよしなにカスタマイズしても良さそうです。  
と言うのも、アプリケーションロジックが流出してしまう点が懸念かなと思ったからです。
`onChange` ロジックを記述してしまうと、常にアプリケーション側の実装に追従する必要があります。その結果、Storybook とアプリケーションの実装の間に不整合が生じたり、メンテナンスコストが増加する可能性もあります。

別の手段として、カスタムフックなど再利用可能な関数を用意し、Storybook とアプリケーション両方で同じフックを利用しても良さそうです。

```typescript
// Custom Hooks

const useInput = (
  initialValue: string
): {
  value: string;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
} => {
  const [value, setValue] = useState(initialValue);
  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };
  return { value, onChange };
};
```

```tsx
export const Default: Story = {
  ...,
  render: function Render(args) {
    const input = useInput(args.value);
    return <Form {...args} {...input} />;
  },
};
```

カスタムフック `useInput` を用意し、`onChange` の実装を隠蔽してみました。  
こちらでも問題なく動きました。

### ページ遷移

Storybook 上でページ遷移できるようにします。  
今回は、あるページ（A）内のリンクをクリックすると、別のページ（B）へ遷移するケースを例にします。

```tsx
// Page A

import { Link } from "react-router-dom";

const PageA: FC = () => {
  return (
    <>
      <h1>Page A</h1>
      <Link to={"path/to/page-b"}>Page B</Link>
    </>
  );
};

// Page B

const PageB: FC = () => {
  return <h1>Page B</h1>;
};

// Router（BrowserRouter）

import { RouteObject } from "react-router-dom";

const router: RouteObject[] = [
  {
    path: `/path/to/page-a`,
    element: <PageA />,
  },
  {
    path: `/path/to/page-b`,
    element: <PageB />,
  },
];
```

上記のページを Story にします。  
Storybook 上では、最初に Page A が表示され、ページ内のリンクをクリックすると Page B に遷移する動きを想定しているため、Page A の Story を作成し、且つ、Story に Routing 機能も追加してあげます。

```tsx
// PageA.stories.tsx

import { createMemoryRouter, RouterProvider } from "react-router-dom";
import type { Meta, StoryObj } from "@storybook/react";

const meta: Meta<typeof PageA> = {
  title: "Pages/PageA",
  component: PageA,
};

export default meta;
type Story = StoryObj<typeof meta>;

const router = createMemoryRouter(router, {
  initialEntries: ["/path/to/page-a"],
});

export const Default: Story = {
  render: function Render() {
    return <RouterProvider router={router} />;
  },
};
```

Routing には、React Router の `MemoryRouter` を使います。`MemoryRouter` は、ブラウザの URL 履歴を使う代わりに、メモリ内の履歴を使います。つまり、ブラウザのアドレスバーを書き換えることなく、内部的にページ遷移が行われるということです。  
Storybook とアプリケーションの URL パスが異なるため、アプリケーション同様に、`BrowserRouter` を使うと、Storybook 上に存在しないページに遷移されてしまいます。

Storybook を見てみると、ブラウザのアドレスバーは変わらずに、ページ遷移されることが確認できると思います。

![storybook-chromatic-21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/9d3f90c5-006d-1698-09a0-e18383430853.gif)

### Redux Store への接続

Redux Store に接続し、状態を持つコンポーネントの Story を作成します。  
アプリケーション側の Store のセットアップは完了している前提とします。（Store のセットアップについては、[Redux Toolkit サクッと入門](https://qiita.com/Jiei-S/items/dc7c70531a3d3a1ed2a7) を参考にしていただければと思います）

まずは、Store のモックを作成します。

```tsx
// Mock Store

import { configureStore, createSlice, EnhancedStore } from "@reduxjs/toolkit";

export const mockStore = (): EnhancedStore<RootState> => {
  return configureStore({
    reducer: {
      posts: createSlice({
        name: "posts",
        initialState: {
          data: [
            {
              id: "1",
              name: "post1",
            },
            {
              id: "2",
              name: "post2",
            },
          ],
        },
        reducers: {},
      }).reducer,
    },
  });
};
```

次に、Provider のモックを作成し、Store を渡すのと、Provider をグローバルに適用します。

```tsx
// Mock Provider

import { Provider as ReduxProvider } from "react-redux";

const Provider = (Story, context) => {
  return (
    <ReduxProvider store={mockStore()}>
      <Story />
    </ReduxProvider>
  );
};

// .storybook/preview.ts

const preview: Preview = {
  ...,
  decorators: [Provider],
};
```

デコレータは、Story をラップ・拡張する機能です。

Store の準備ができましたので、実際に接続してみます。  
今回は、Store から取得したデータを表示するページを例にします。

```tsx
import useUserStore from "path/to/store";

const Page: FC = () => {
  const { postsState } = usePostStore();

  return (
    <div>
      <h1>Page</h1>
      {postsState.data.map((post) => (
        <div key={post.id}>{post.name}</div>
      ))}
    </div>
  );
};
```

```tsx
// Page.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";

const meta: Meta<typeof Page> = {
  title: "Page",
  component: Page,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
```

`preview.ts` の設定により、Store はグローバルに適用されているため、Story 側での接続処理は不要です。  
Storybook 画面には、Store から取得したデータ（post1, post2）が表示されます。

### Mock Service Worker を使った API 通信

API 通信を伴うページの Story を作成します。  
API は、Mock Service Worker（MSW）を使ってモック化します。MSW は、API リクエストをインターセプトし、モックデータを返すことができます。

まずは、MSW のセットアップです。

```bash
$ yarn add msw msw-storybook-addon -D
$ npx msw init .storybook/public
```

完了すると、`.storybook/public` に `mockServiceWorker.js` が作成されます。  
次に、MSW を Storybook に適用します。

```ts
// .storybook/preview.ts

import { initialize, mswDecorator } from "msw-storybook-addon";

initialize({
  onUnhandledRequest: "bypass",
});

const preview: Preview = {
  ...,
  decorators: [..., mswDecorator],
};

// .storybook/main.ts

const config: StorybookConfig = {
  ...,
  staticDirs: ["./public"],
};
```

`onUnhandledRequest` は、一致するリクエストハンドラーが存在しない場合の挙動を指定します。デフォルトは、警告ログをブラウザのコンソールに出力します。ログ出力不要な場合は、`bypass` を指定します。

セットアップが完了したので、MSW が有効になっているか確認します。ブラウザのコンソールに `[MSW] Mocking enabled.` が出力されていれば OK です。

![storybook-chromatic-22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/81461684-d1ec-7dbc-8178-32230f1331e0.png)

次に、リクエストハンドラーのモックを作成し、指定の API リクエストをインターセプトできるようにします。

```ts
// Mock API Handlers

import { http, HttpResponse } from "msw";

const mockResponse = [...Array(3)].map((_, i) => ({
  id: uuid(),
  name: `post${i + 1}`,
}));

export default [
  http.get("/posts", () => {
    return HttpResponse.json(mockResponse);
  }),
];
```

Storybook にモックを適用します。

```ts
// .storybook/preview.ts

const preview: Preview = {
  parameters: {
    ...,
    msw: {
      handlers: MockAPIHandlers,
    },
  },
};
```

今回は、レスポンスデータを表示するページを例にして、動作確認してみます。

```tsx
import useUserStore from "path/to/store";

const Page: FC = () => {
  const { postsState, dispatchGetPosts } = usePostStore();

  useEffect(() => {
    dispatchGetPosts(); // 内部で GET /posts リクエスト
  }, [dispatchGetPosts]);

  return (
    <div>
      <h1>Page</h1>
      {postsState.data.map((post) => (
        <div key={post.id}>{post.name}</div>
      ))}
    </div>
  );
};
```

```tsx
// Page.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";

const meta: Meta<typeof Page> = {
  title: "Page",
  component: Page,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
```

`preview.ts` の設定により、API モックの部分はグローバルに適用されているため、Story 側での処理は不要です。

Storybook 画面には、モックのレスポンスデータが表示されます。  
また、ブラウザのコンソールにもリクエストがインターセプトされた旨のログが出力されていると思います。

![storybook-chromatic-23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/a180b187-6952-53b5-969b-99d98be4d4be.png)

以上で、UI コンポーネントカタログの作成は完了です。  

インタラクティブな部分も再現することで、実際のアプリケーションに近いコンポーネントカタログを作成でき、カタログとしての価値が高まったのではないかと思います。

# インタラクションテスト

Storybook を使って、インタラクションテストを行います。  
今回は、以下のシナリオを想定したフォームを例にします。

正常

- ページ A からページ B（フォーム）へ遷移
- 項目を入力し、送信ボタンをクリック
- 送信後、ページ B からページ A へ遷移

異常

- ページ A からページ B（フォーム）へ遷移
- 不正値を入力
- エラーが表示され、送信できない

上記シナリオ（ユーザー操作）を Storybook 上でシミュレートし、以下のテストを行います。

正常

- ページ A からページ B（フォーム）へ遷移できること --- ①
- フォームの初期状態が正しいこと --- ②
- 項目を入力し、送信できること --- ③
- 送信後、ページ B からページ A へ遷移できること --- ④

異常

- ページ A からページ B（フォーム）へ遷移できること --- ⑤
- 不正値を入力した時、エラーが表示されること、送信ボタンが非活性になること --- ⑥

※ 番号は、後述のソースコード内のコメントと対応しています

## セットアップ

まずは、テスト関連のライブラリインストールと、Storybook の Addon を追加します。

```bash
$ yarn add @storybook/testing-library @storybook/jest @storybook/addon-interactions -D
```

```typescript
// .storybook/main.ts

const config: StorybookConfig = {
  ...,
  addons: [
    ...,
    "@storybook/addon-interactions",
  ],
};
```

## テストコードの作成・実行

ユーザー操作をシミュレートするには `play` 関数を使います。`play` 関数に定義した内容に従って、Storybook 上で操作されます。  
（`play` 関数は Story のレンダリング時に実行されるため、シミュレートする操作も自動で実行されます）  
操作結果は、Jest の関数を使って検証します。

```tsx
// Test.stories.tsx

import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { expect } from "@storybook/jest";
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent, waitFor } from "@storybook/testing-library";

const meta: Meta<typeof PageA> = {
  title: "PageA",
  component: PageA,
};

export default meta;
type Story = StoryObj<typeof meta>;

const router = createMemoryRouter(router, {
  initialEntries: ["/path/to/page-a"],
});

export const Success: Story = {
  render: function Render() {
    return <RouterProvider router={router} />;
  },
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
    const user = userEvent.setup();

    // ①
    await step("Transition to Page B", async () => {
      // Page B へのリンクをクリックして、ページ遷移
      await user.click(canvas.getByRole("link", { name: "Page B" }));
    });

    // フォーム内の要素を取得
    const name = canvas.getByLabelText("Name");
    const submitButton = canvas.getByRole("button", { name: "Submit" });

    // ②
    await step("Initial", async () => {
      // 送信ボタンがページ内に存在するかチェック
      await expect(submitButton).toBeInTheDocument();
      // 送信ボタンが非活性かチェック
      await expect(submitButton).toBeDisabled();
      // 項目がページ内に存在するかチェック
      await expect(name).toBeInTheDocument();
      // 項目の初期値が空文字かチェック
      await expect(name).toHaveValue("");
    });

    // ③
    await step("Enter", async () => {
      // 項目に値を入力
      await user.type(name, "John");
    });

    await step("Submit", async () => {
      // 入力値が反映されているかチェック
      await expect(name).toHaveValue("John");
      // 送信ボタンが活性かチェック
      await expect(submitButton).toBeEnabled();
      // 送信ボタンをクリック
      await user.click(submitButton);
    });

    // ④
    await step("Transition to Page A", async () => {
      // ページ遷移後、カレントの URL パスが Page A になっているかチェック
      await waitFor(
        () => expect(router.state.location.pathname).toEqual("/path/to/page-a"),
        { timeout: 5000 }
      );
    });
  },
};
```

`canvas` から `getByRole` や `getByLabelText` などの検索メソッドを使って、画面上の要素を取得します。  
`userEvent` でクリックや入力などのユーザー操作をシミュレートしています。  
`step` は、インタラクションをラベル付きでグルーピングするための関数です。

Story を見ると、定義した操作が自動で実行されると思います。また、Interactions パネルでは、シミュレートされた操作と検証結果が確認できます。

![storybook-chromatic-12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/a5b182c3-6b07-3481-9594-a21a43d9b5ab.png)

次に、異常系のテストを作成します。

```tsx
export const ValidationError: Story = {
  ...,
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
    const user = userEvent.setup();

    .
    .
    .

    // ⑤ は Success と同じなので省略

    // ⑥
    await step("Empty", async () => {
      // 項目をクリア
      await user.clear(name);
      // 表示されるエラーメッセージが正しいかチェック
      await expect(canvas.getByText("required")).toBeInTheDocument();
      // 送信ボタンが非活性かチェック
      await expect(submitButton).toBeDisabled();
    });

    await step("Max", async () => {
      // 項目に最大文字数を超過する値を入力
      await user.type(name, "a".repeat(21));
      // 表示されるエラーメッセージが正しいかチェック
      await expect(canvas.getByText("max length is 20")).toBeInTheDocument();
      // 送信ボタンが非活性かチェック
      await expect(submitButton).toBeDisabled();
    });
  },
};
```

以上で、インタラクションテストは完了です。  
今回は触れませんでしたが、CLI からもテストが実行できるのでご参考までに。
（参考：[Test runner](https://storybook.js.org/docs/writing-tests/test-runner)）

# Visual Regression Testing

Chromatic を使って、Visual Regression Testing を行います。  
予め、Storybook も用意しておくことを前提とします。

## Chromatic のセットアップ

[Chromatic](https://www.chromatic.com/start?startWithSignup=true) に Sign Up します。（今回は GitHub アカウントで登録しました）

GitHub 認証が完了すると、プロジェクト作成画面が表示されます。「Choose from GitHub」から対象のリポジトリを選択します。

![storybook-chromatic-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/ee163717-57b9-98a8-ea12-c387a4a2a6e0.png)

Chromatic のインストールと Storybook を Publish するためのコマンドが表示されるので、実行します。

![storybook-chromatic-4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/cd6b7f71-1896-60b4-0117-cd57349e1f7d.png)

Publish 完了の画面が表示されます。

![storybook-chromatic-5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/d8248c9b-2ae9-4258-4091-4b88eb7828ce.png)

「Catch a UI change」から遷移すると、再度、Publish するためのコマンドが表示されます。これは、前後のキャプチャを比較し、差異を検出するためのステップです。  
1 回目に Publish した Storybook から適当にコンポーネントを変更し、再度 Publish します。

![storybook-chromatic-6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/c38010a9-1bb6-1b5e-52f0-a2012ae36e54.png)

2 回目の Publish が完了すると、差異が検出された旨の画面が表示されます。

![storybook-chromatic-7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/cc224aed-a83c-a779-45f0-f96ba76aeca9.png)

以降のステップは、画面に従って進めていけば OK です。  

全ステップを終え、Build の詳細画面が確認できればセットアップ完了です。

![storybook-chromatic-8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/4964531a-380d-66cb-e046-8938701b5000.png)

## テストの実行

テストの実行については、前章のセットアップで軽く触れましたが、本章ではもう少し詳しく見ていきます。

まず、適当にコンポーネントを変更し、コミットします。その上で、Storybook を Publish し、該当 Build 画面を確認します。  
対象のコミットや差異が検出された Story、差異の承認状況、Story 一覧などが確認できます。

![storybook-chromatic-9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/c0fd7ebd-c22a-6197-7467-ef6ed4b94fd6.png)

差異が検出された Story の詳細に遷移すると、Diff 画面が表示され、差異の箇所を確認できます。緑色にハイライト部分が差異の箇所です。

![storybook-chromatic-10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/045fc5dd-89ae-4c9d-0ff3-55af21ca21d6.png)

差異の箇所に対してコメントしたり、ディスカッションもできるため、UI レビューにも使えます。  
右上にある「Deny」or「Accept」ボタンで、差異の承認/拒否を選択できます。承認ステップは、CI に組み込むことも可能です。CI については後述します。

![storybook-chromatic-11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/99d7e918-dbd1-652a-e83c-00c4613e406f.png)

ここまでは、Visual Regression Testing について見てきましたが、インタラクションテストもバッググラウンドで実行されています。  
「Interaction」のラベルが表示されていれば機能しています。

![storybook-chromatic-18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/79f4518e-3baf-4279-8130-8a705a23b503.png)

以上で、Visual Regression Testing は完了です。

Visual Regression Testing もインタラクションテストも、Storybook 側で実装したものを Publish するだけで自動で実行されるため、手軽にテストを導入できるのではないかと思います。

## UI レビュー

Chromatic には、Review という機能があります。  
Review は、Pull Request と同期しており、Pull Request が Open されると、Review が自動で作成され、アクティビティや差異の箇所などが確認できます。

![storybook-chromatic-15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/00f064a8-8b02-da53-8a96-a558f4cd6719.png)

![storybook-chromatic-16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/769255d9-3472-79b7-b7ed-f66f89abb4a3.png)

差異の箇所の確認やコメントもできます。

![storybook-chromatic-17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/0f0244a5-1e21-c0d9-a720-f4d20d9277f1.png)

Review は、Pull Request がマージされると自動で Close されます。  
UI レビューのプロセスを一元管理できるため、チーム開発においても有用だと思います。

# CI にテストを組み込む

最後の章では、CI にテストを組み込みます。

内容としては、Chromatic へ Storybook を Publish するワークフローを CI に組み込みます。これにより、前章の Visual Regression Testing で触れたように、Chromatic 側で諸々の処理が実行されます。

今回は、GitHub Actions を使って、Pull Request が Open された時に実行されるようにします。

```yaml
# .github/workflows/chromatic.yml

name: Chromatic

on:
  pull_request:
    types:
      - opened
    paths:
      - src/**

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install Dependencies
        run: |
          corepack enable
          yarn install

      - name: Publish
        uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
```

GitHub Repository の Secrets に、`CHROMATIC_PROJECT_TOKEN` の値も登録しておきます。  
Token は、Chromatic の Manage 画面から取得できます。

![storybook-chromatic-13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/38104695-d5a1-d241-c74f-2f5ae86a3ac6.png)

次に、ベースブランチから適当に変更を加え、Pull Request を Open します。  
GitHub Actions が実行され、Publish が完了すると、Chromatic の UI Review と UI Tests のステータスも表示されるようになります。UI Review と UI Tests は、Chromatic 側と同期されています。

![storybook-chromatic-14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/e455697f-7201-4d7c-55e1-d7dd1770e04c.png)

Chromatic 側で、UI Tests のステータスを Accept に、UI Review のステータスを Approve に変更してみると、Pull Request にも反映されます。

![storybook-chromatic-19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/950cc5a1-275c-a8fc-6ff8-09dfe624d2d3.png)

これで、CI に組み込むことができました。  

UI レビューや Visual Regression Testing、インタラクションテストであったり、E2E や Lint といった他のテストも含め、一連のレビュー・テストステータスを GitHub 上で確認できるようになるので、管理面においても導入メリットがあると思いました。

以上です。

# Ref

- [Storybook](https://storybook.js.org/)
- [Mock Service Worker](https://mswjs.io/)
- [Chromatic](https://www.chromatic.com/)