Next.js + Storybook + PlaywrightをChromaticでビジュアルテスト自動化する

PS-SLの佐々木です。

アドベントカレンダー13日目になります

この記事では、Next.js 14プロジェクトにStorybookとPlaywright E2Eテストを導入し、Chromaticを使ってGitHub Actionsで自動ビジュアルテストを実現するまでの過程を解説します。


Chromaticとは

Chromaticは、Storybookチームが提供するビジュアルテスト・UIレビュープラットフォームです。

なぜChromaticを使うのか

課題 Chromaticでの解決
CSSの変更が他のコンポーネントに影響していないか不安 全Storiesのスナップショットを自動比較
PRレビューでUIを確認するのが面倒 プレビューURLが自動でPRにコメントされる
デザインシステムのドキュメントが古くなる 常に最新のStorybookがホスティングされる
E2Eテストの画面キャプチャを管理したい Playwright連携でE2Eもビジュアルテスト化

今回のプロジェクト構成

project/
├── frontend/
│   ├── src/
│   │   ├── components/
│   │   │   ├── ui/          # shadcn/ui ベースの共通コンポーネント
│   │   │   │   ├── button.tsx
│   │   │   │   ├── button.stories.tsx
│   │   │   │   ├── card.tsx
│   │   │   │   ├── card.stories.tsx
│   │   │   │   ├── input.tsx
│   │   │   │   ├── input.stories.tsx
│   │   │   │   └── textarea.tsx
│   │   │   ├── chat/         # チャット機能コンポーネント
│   │   │   │   ├── MessageList.tsx
│   │   │   │   ├── MessageList.stories.tsx
│   │   │   │   ├── MessageInput.tsx
│   │   │   │   └── ModeSelector.tsx
│   │   │   └── import/       # インポート機能コンポーネント
│   │   │       ├── FileUploader.tsx
│   │   │       └── ImportProgress.tsx
│   │   └── app/
│   ├── e2e/                  # Playwright E2Eテスト
│   │   └── home.spec.ts
│   ├── .storybook/
│   │   ├── main.ts
│   │   └── preview.ts
│   └── package.json
├── backend/
└── .github/
    └── workflows/
        └── ci.yml

技術スタック

  • Next.js 14 (App Router)
  • shadcn/ui + Tailwind CSS
  • Storybook 8.4
  • Playwright
  • @chromatic-com/playwright

Storybookのセットアップ

1. Storybookの初期化

cd frontend
npx storybook@latest init

2. 設定ファイル

.storybook/main.ts:

import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-links',
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
  staticDirs: ['../public'],
};
export default config;

.storybook/preview.ts:

import type { Preview } from '@storybook/react';
import '../src/app/globals.css';  // Tailwind CSSを読み込む
const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};
export default preview;

3. package.jsonのスクリプト

{
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "chromatic": "chromatic --exit-zero-on-changes"
  }
}

コンポーネントのStories作成

実際に作成したStoriesの例を紹介します。

UIコンポーネント(Button)

src/components/ui/button.stories.tsx:

import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './button';
const meta: Meta<typeof Button> = {
  title: 'UI/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'],
    },
    size: {
      control: 'select',
      options: ['default', 'sm', 'lg', 'icon'],
    },
  },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
  args: {
    children: 'Button',
    variant: 'default',
  },
};
export const Secondary: Story = {
  args: {
    children: 'Secondary',
    variant: 'secondary',
  },
};
export const Outline: Story = {
  args: {
    children: 'Outline',
    variant: 'outline',
  },
};
export const Destructive: Story = {
  args: {
    children: 'Destructive',
    variant: 'destructive',
  },
};
export const Small: Story = {
  args: {
    children: 'Small',
    size: 'sm',
  },
};
export const Large: Story = {
  args: {
    children: 'Large',
    size: 'lg',
  },
};

機能コンポーネント(MessageList)

チャット機能のメッセージ一覧コンポーネント。様々な状態をStoriesで表現します。

src/components/chat/MessageList.stories.tsx:

import type { Meta, StoryObj } from '@storybook/react';
import { MessageList } from './MessageList';
import type { Message } from '@/types';
const meta: Meta<typeof MessageList> = {
  title: 'Chat/MessageList',
  component: MessageList,
  parameters: {
    layout: 'fullscreen',
  },
  tags: ['autodocs'],
  decorators: [
    (Story) => (
      <div className="h-[500px] bg-gray-50">
        <Story />
      </div>
    ),
  ],
};
export default meta;
type Story = StoryObj<typeof meta>;
// モックデータ
const mockMessages: Message[] = [
  {
    id: '1',
    role: 'user',
    content: '冷却システムの変更点を教えてください',
    timestamp: new Date('2024-01-15T10:00:00'),
  },
  {
    id: '2',
    role: 'assistant',
    content: '冷却システムには以下の変更点があります:\n\n1. 冷却ファンの形状を変更\n2. ヒートシンクの素材を変更',
    sources: [
      { id: 'src-1', content: 'No.15: 冷却システム', excel_row: 15 },
    ],
    timestamp: new Date('2024-01-15T10:00:05'),
  },
];
// 空の状態
export const Empty: Story = {
  args: {
    messages: [],
    isLoading: false,
    onExport: () => {},
  },
};
// メッセージがある状態
export const WithMessages: Story = {
  args: {
    messages: mockMessages,
    isLoading: false,
    onExport: () => {},
  },
};
// ローディング状態
export const Loading: Story = {
  args: {
    messages: [mockMessages[0]],
    isLoading: true,
    onExport: () => {},
  },
};
// エクスポートボタン付き
export const WithExportButton: Story = {
  args: {
    messages: [
      ...mockMessages,
      {
        ...mockMessages[1],
        id: '3',
        exportReady: true,  // エクスポート可能フラグ
      },
    ],
    isLoading: false,
    onExport: () => alert('Export clicked'),
  },
};

インポート機能(ImportProgress)

ファイルインポートの進捗表示コンポーネント。各状態をStoriesで網羅します。

src/components/import/ImportProgress.stories.tsx:

import type { Meta, StoryObj } from '@storybook/react';
import { ImportProgress } from './ImportProgress';
const meta: Meta<typeof ImportProgress> = {
  title: 'Import/ImportProgress',
  component: ImportProgress,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  decorators: [
    (Story) => (
      <div className="w-[400px]">
        <Story />
      </div>
    ),
  ],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = {
  args: {
    status: 'idle',
    result: null,
  },
};
export const Uploading: Story = {
  args: {
    status: 'uploading',
    result: null,
  },
};
export const Success: Story = {
  args: {
    status: 'success',
    result: {
      success: true,
      message: 'Excelファイルのインポートが完了しました',
      documentCount: 25,
    },
  },
};
export const Error: Story = {
  args: {
    status: 'error',
    result: {
      success: false,
      message: 'ファイル形式が不正です。xlsx または xls ファイルをアップロードしてください。',
    },
  },
};

Playwright E2EテストのChromatic対応

1. パッケージのインストール

npm install --save-dev @chromatic-com/playwright

2. E2Eテストの修正

通常の@playwright/testの代わりに、@chromatic-com/playwrightからインポートします。

e2e/home.spec.ts:

// Before: import { test, expect } from '@playwright/test';
// After:
import { test, expect } from '@chromatic-com/playwright';

test.describe('Home Page', () => {
  test('should display the header', async ({ page }) => {
    await page.goto('/');
    await expect(page.locator('h1')).toContainText('Excel RAG');
    // テスト終了時に自動的にスナップショットが取得される
  });

  test('should have navigation links', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByRole('link', { name: 'QA' })).toBeVisible();
    await expect(page.getByRole('link', { name: 'Import' })).toBeVisible();
  });

  test('should display mode selector', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByRole('button', { name: 'QA' })).toBeVisible();
    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
  });

  test('should have message input', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByPlaceholder('質問を入力してください')).toBeVisible();
  });
});

test.describe('Import Page', () => {
  test('should display file uploader', async ({ page }) => {
    await page.goto('/import');
    await expect(page.locator('h2')).toContainText('Excel帳票インポート');
  });
});

3. Playwright設定

playwright.config.tsは通常通りでOK:

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

GitHub Actionsでの自動化

StorybookとE2Eで別々のChromaticプロジェクトを使用する設定です。

完全なワークフロー設定

.github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [master, develop]
  pull_request:
    branches: [master, develop]
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
jobs:
  # ===========================
  # Chromatic - Storybook
  # ===========================
  chromatic-storybook:
    name: Chromatic - Storybook
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: frontend
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 全履歴取得(差分比較に必要)

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Build Storybook
        run: npm run build-storybook

      - name: Publish Storybook to Chromatic
        id: chromatic
        uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_STORYBOOK_TOKEN }}
          workingDir: frontend
          storybookBuildDir: storybook-static
          exitZeroOnChanges: true
          autoAcceptChanges: master
          onlyChanged: true

      - name: Comment Storybook URL on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const storybookUrl = '${{ steps.chromatic.outputs.storybookUrl }}';
            const buildUrl = '${{ steps.chromatic.outputs.buildUrl }}';

            if (storybookUrl) {
              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: `## Storybook Preview\n\n- [View Storybook](${storybookUrl})\n- [View Chromatic Build](${buildUrl})`
              });
            }

  # ===========================
  # Chromatic - E2E (Playwright)
  # ===========================
  chromatic-e2e:
    name: Chromatic - E2E
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: frontend
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run Playwright tests
        run: npx playwright test

      - name: Publish E2E to Chromatic
        id: chromatic-e2e
        uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_E2E_TOKEN }}
          workingDir: frontend
          playwright: true
          exitZeroOnChanges: true

必要なGitHub Secrets

GitHubリポジトリの Settings > Secrets and variables > Actions で設定:

Secret名 用途 取得方法
CHROMATIC_STORYBOOK_TOKEN Storybook用 Chromaticで「Storybook」プロジェクトを作成
CHROMATIC_E2E_TOKEN E2E用 Chromaticで「E2E」プロジェクトを作成

なぜ分けるのか?

StorybookとE2Eは性質が異なるため、別プロジェクトで管理する方が見やすくなります:

  • Storybook: コンポーネント単位のスナップショット
  • E2E: ページ全体のスナップショット

Chromaticの画面を見てみる

実際にPRを作成したりtargeブランチにPRがマージされると以下のように確認することができます。

Buildごとにどのコンポーネントがどのように変更されているかがスナップショットで確認できるようになっており、これを承認したりコメントを残したりすることができます。

UIはソースコードを眺めているだけではなかなか差分がわかりにくいため実際のビジュアルを手軽にできるのはとても良い機能だと思います。

ハマったポイントと解決策

1. chromatic --playwrightでアーカイブが見つからないエラー

Chromatic archives directory cannot be found:
/path/to/frontend/test-results/chromatic-archives

原因: Playwrightテストを実行する前にchromatic --playwrightを実行していた

解決策: 先にPlaywrightテストを実行してからChromaticにアップロード

- name: Run Playwright tests
  run: npx playwright test

- name: Publish E2E to Chromatic
  uses: chromaui/action@latest
  with:
    playwright: true

2. takeArchiveが見つからないエラー

Module '"@chromatic-com/playwright"' has no exported member 'takeArchive'.

原因: 古いドキュメントを参照していた

解決策: @chromatic-com/playwrightからはtestexpectをインポートするだけでOK。テスト終了時に自動的にスナップショットが取得される。

// ❌ 間違い
import { takeArchive } from '@chromatic-com/playwright';
// ✅ 正しい
import { test, expect } from '@chromatic-com/playwright';

3. Storybookビルドエラー(webpack関連)

Module not found: TypeError: Cannot read properties of undefined (reading 'tap')

原因: @chromatic-com/playwrightが古いStorybookバージョンを要求し、バージョン競合が発生

解決策: Storybookのバージョンを8.4.2に統一

npm install @storybook/nextjs@8.4.2 @storybook/addon-essentials@8.4.2 \
  @storybook/addon-interactions@8.4.2 @storybook/addon-links@8.4.2 \
  @storybook/blocks@8.4.2 @storybook/react@8.4.2 @storybook/test@8.4.2 \
  storybook@8.4.2

4. ChromaticのstorybookBuildDirが必要

CIでStorybookをビルドしてからアップロードする場合、ビルド済みディレクトリを指定する必要がある。

- name: Build Storybook
  run: npm run build-storybook

- name: Publish to Chromatic
  uses: chromaui/action@latest
  with:
    storybookBuildDir: storybook-static  # これを指定

まとめ

導入後のワークフロー

  1. 開発者がPRを作成
  2. GitHub ActionsがStorybookをビルド → Chromaticにアップロード
  3. GitHub ActionsがE2Eテスト実行 → Chromaticにアップロード
  4. PRにStorybookプレビューURLが自動コメント
  5. レビュアーがChromaticで差分を確認
  6. 問題なければマージ → masterブランチは自動承認

作成したファイル一覧

frontend/
├── src/components/
│   ├── ui/
│   │   ├── button.stories.tsx
│   │   ├── card.stories.tsx
│   │   ├── input.stories.tsx
│   │   └── textarea.stories.tsx
│   ├── chat/
│   │   ├── MessageList.stories.tsx
│   │   ├── MessageInput.stories.tsx
│   │   └── ModeSelector.stories.tsx
│   ├── import/
│   │   ├── FileUploader.stories.tsx
│   │   └── ImportProgress.stories.tsx
│   └── Introduction.mdx          # デザインシステムドキュメント
├── e2e/
│   └── home.spec.ts              # Chromatic対応済み
├── .storybook/
│   ├── main.ts
│   └── preview.ts
└── package.json

.github/workflows/
└── ci.yml                        # Chromatic自動化設定

得られた効果

Before After
UIレビューは手動で各画面を確認 PRにプレビューURLが自動投稿
CSS変更の影響範囲が不明 全コンポーネントのスナップショットで差分検出
E2Eテストは結果のみ確認 画面キャプチャも自動で比較・管理
デザインシステムのドキュメントが陳腐化 常に最新のStorybookがホスティング

Chromaticを導入することで、UIの品質を担保しながら開発スピードを落とさないワークフローが実現できました。


参考リンク

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です