はじめに
フロントエンド開発において「気づかないうちに UI が崩れていた」という経験はないでしょうか。CSS の修正が別のコンポーネントに影響していたり、ライブラリのアップデートで微妙にレイアウトがずれていたり。こうした問題を自動で検知するのが VRT(Visual Regression Testing)です。
VRT はコンポーネントのスクリーンショットを撮影し、以前のベースラインと比較することで視覚的なデグレを検出します。本記事ではPlaywright の toHaveScreenshot() を使って Storybook コンポーネントの VRT 環境を構築する手順を解説します。
使用技術
- Next.js 15 / React 19 / TypeScript
- Storybook 10(@storybook/nextjs-vite)
- Playwright(@playwright/test)
- Docker(Playwright 公式イメージ)
- GitHub Actions
VRT とは
VRT はコンポーネントの 見た目をスナップショットとして保存し、変更前後を自動比較 するテスト手法です。
1回目の実行: スクリーンショットを撮影 → ベースラインとして保存
2回目以降: スクリーンショットを撮影 → ベースラインと比較 → 差異があれば失敗
実際にブラウザでレンダリングした結果を比較するため、CSSの変化やフォントのずれも検出できます。
なぜ Storybook と組み合わせるのか
Storybook はコンポーネントをストーリーとして独立した状態で管理しています。各ストーリーは一意の URL(iframeURL)でアクセスできるため、VRT の対象として非常に扱いやすいです。
/iframe.html?id=コンポーネントID--ストーリー名&viewMode=story
この URL に Playwright でアクセスしてスクリーンショットを撮るだけで、コンポーネント単位の VRT が実現できます。
構成技術の選定
toHaveScreenshot() を選んだ理由
Playwright には toHaveScreenshot() というスクリーンショット比較の組み込みメソッドがあります。
await expect(page).toHaveScreenshot('button-primary.png');
jest-image-snapshot などのサードパーティライブラリを使う方法もありますが、toHaveScreenshot() は以下の点で優れています。
- Playwright に組み込みのため追加インストール不要
- アニメーションの自動無効化オプションがある
- CI との連携が容易
- HTML レポートで差分を視覚的に確認できる
実装手順
1. プロジェクトのセットアップ
npx create-next-app@latest vrt-test
cd vrt-test
npx storybook@latest init --builder vite
2. Playwright のインストール
npm install -D @playwright/test
npx playwright install chromium
vitest や jest-image-snapshot は今回不使用です。Playwright standalone に統一します。
3. playwright.config.ts の作成
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/vrt',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['list'],
],
use: {
baseURL: 'http://localhost:6006',
viewport: { width: 1280, height: 720 },
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 100, // 許容するピクセル差異数
animations: 'disabled',
caret: 'hide',
},
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
// Storybook をテスト実行前に自動起動
webServer: { command: 'npm run storybook',
url: 'http://localhost:6006',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
snapshotPathTemplate: 'tests/vrt/__snapshots__/{testFilePath}/{arg}{ext}',
});
webServer の設定がポイントです。playwright test 実行時に Storybookを自動で起動し、テスト完了後に停止します。ローカルでは起動中のサーバーを再利用(reuseExistingServer: true)し、CIでは毎回新規起動します。
4. VRT テストファイルの作成
import { test, expect } from '@playwright/test';
const STORYBOOK_ROOT = '#storybook-root';
test.describe('Button VRT', () => {
test('Primary', async ({ page }) => {
await page.goto('/iframe.html?id=example-button--primary&viewMode=story');
await page.waitForSelector(STORYBOOK_ROOT);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('button-primary.png');
});
test('Secondary', async ({ page }) => {
await page.goto('/iframe.html?id=example-button--secondary&viewMode=story');
await page.waitForSelector(STORYBOOK_ROOT);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('button-secondary.png');
});
});
storybook-rootの表示を待ってからnetworkidleを確認することで、コンポーネントが完全にレンダリングされた状態でスクリーンショットを撮影できます。インタラクションが必要なストーリー(ログイン後の状態など)は Playwright で操作を再現します。
test('LoggedIn(ログインアクション後)', async ({ page }) => {
await page.goto('/iframe.html?id=example-page--logged-out&viewMode=story');
await page.waitForSelector(STORYBOOK_ROOT);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /Log in/i }).click();
await page.waitForSelector('button:text("Log out")');
await expect(page).toHaveScreenshot('page-logged-in.png');
});
5. ベースラインスナップショットの生成
npx playwright test --update-snapshots
tests/vrt/_snapshots_/ 配下に PNG ファイルが生成されます。これをリポジトリにコミットしてベースラインとして管理します。
ハマりどころ:macOS と Linux の環境差異問題
問題
ローカル(macOS)でスナップショットを生成して CI(Linux)で比較すると、意図しない変更がないにもかかわらず常にテストが失敗します。
原因は OS ごとにフォントのアンチエイリアス処理が異なるためです。同じコンポーネントでも macOS と Linux ではピクセル単位で微妙に見た目が違います。
解決策:Docker で環境を統一する
Playwright 公式の Linux Docker イメージを使い、ローカルも CI も同じ環境でテストを実行します。
services:
playwright:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
working_dir: /app
volumes:
- .:/app
- /app/node_modules # Linux 用 node_modules を分離
- pnpm_store:/pnpm_store # pnpm ストアをホストと分離
entrypoint:
[
"sh",
"-c",
"npm install -g pnpm && pnpm config set store-dir /pnpm_store && pnpm install --frozen-lockfile && pnpm exec playwright test
\"$@\"",
"sh",
]
environment:
- CI=true
volumes:
pnpm_store:
注意点
- node_modules は Docker ボリュームに分離します。macOS 用バイナリと Linux 用バイナリは互換性がないためです
- pnpm_store も同様にホストと分離し、コンテナ内の変更がホスト側に同期されないようにします
- Docker イメージのバージョンは @playwright/test のバージョンと一致させる必要があります
package.json にスクリプトを追加:
{
"scripts": {
"test:vrt": "playwright test",
"test:vrt:update": "playwright test --update-snapshots",
"test:vrt:docker": "docker compose run --rm playwright",
"test:vrt:docker:update": "docker compose run --rm playwright --update-snapshots",
"test:vrt:report": "playwright show-report"
}
}
ローカルでのスナップショット生成・比較は必ず Docker 経由で行います。
ベースライン生成(初回・変更承認時)
npm run test:vrt:docker:update
比較テスト実行
npm run test:vrt:docker
CI 構成(GitHub Actions)
name: VRT
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
vrt:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
options: --user 1001 # セキュリティのため非rootで実行
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run VRT tests
run: pnpm run test:vrt
env:
CI: true
- name: Upload test report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
container に Playwright 公式イメージを指定することで、docker-compose.yml と同じ Linux 環境で CI が実行されます。ブラウザもイメージに含まれているため playwright install は不要です。
--user 1001 について
セキュリティ上の理由で非 root ユーザーで実行しています。npm install -g pnpm はシステムディレクトリへの書き込みが必要なため root 権限が必要ですが、pnpm/action-setup はツールキャッシュに pnpm をインストールするため root 権限不要です。
運用フロー
コンポーネント変更
↓
npm run test:vrt:docker(ローカルで確認)
↓
差分なし ──→ PR 作成 → GitHub Actions が自動 VRT 実行
差分あり
├─ 意図した変更 → npm run test:vrt:docker:update
│ → スナップショット更新をコミット → PR
└─ 意図しない変更 → コード修正して再テスト
テスト失敗時は npm run test:vrt:report で HTML レポートを開くと、期待値・実際の値・差分ハイライトを視覚的に確認できます
まとめ
VRT は一度構築すれば「気づかない UI のデグレ」を自動で検知し続けてくれます。
コンポーネント数が増えるほど手動確認のコストは上がります。早い段階で VRTを導入しておくことで、リファクタリングやライブラリアップデート時の安心感が大きく変わります。ぜひ導入を検討してみてください。