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からはtestとexpectをインポートするだけで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 # これを指定
まとめ
導入後のワークフロー
- 開発者がPRを作成
- GitHub ActionsがStorybookをビルド → Chromaticにアップロード
- GitHub ActionsがE2Eテスト実行 → Chromaticにアップロード
- PRにStorybookプレビューURLが自動コメント
- レビュアーがChromaticで差分を確認
- 問題なければマージ → 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の品質を担保しながら開発スピードを落とさないワークフローが実現できました。

