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の品質を担保しながら開発スピードを落とさないワークフローが実現できました。 参考リンク Chromatic公式ドキュメント chromaui/action (GitHub Action) @chromatic-com/playwright Storybook公式サイト ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Next.js + Storybook + PlaywrightをChromaticでビジュアルテスト自動化する first appeared on SIOS Tech Lab .