背景
今回は、店舗向け集客ツール「aumo Biz」のレポート画面における、フロントエンドテスト基盤構築の取り組みについて紹介します。
aumo Biz のレポート画面は Next.js 15 で構築されており、SNS 広告や Google 広告の運用効果を可視化するダッシュボード機能を提供しています。 この画面は以下のような要素の組み合わせにより、多様な表示パターンが存在します。
- 店舗の種別や契約条件
- 広告媒体の種類
- ユーザー指定の検索キーワード
- 期間条件や対象期間のデータ量
一方、これまでフロントエンドの自動テストが存在しておらず、UI の意図しない変化を機械的に検出できない状態でした。そのため、機能改修のたびに膨大なパターンの目視確認が発生し、継続的な機能改善において心理的・工数的なリスクとなっていました。
また、本プロダクトは少人数で開発を進めており、テストに十分な工数を割ける体制ではありませんでした。 そのため、品質維持と運用負荷の抑制を両立しつつ、コンポーネントの状態定義と UI 確認を開発の起点に据えることで、効率的にテスト資産を蓄積できる Storybook 駆動開発 のフローを導入することにしました。
この開発フローを支える仕組みとして、Playwright の Visual Regression Testing(VRT) 機能を活用し、Storybook 上に定義された各コンポーネントの表示状態を自動で検知・比較する仕組みを構築しています。
本記事では、この Storybook 駆動開発の導入プロセスや、Playwright VRT を活用したテスト基盤の具体的な構築方法について解説します。
環境
- Next.js : 15.3.6
- Storybook : 10.1.7
- Playwright : 1.56.1
Storybook 駆動開発の導入
まず、Storybook 駆動開発を導入するにあたり、既存のコンポーネント構造を見直してテスト可能な状態に整理することから始めました。
これまでの実装は、コンポーネント内にデータフェッチや副作用のある処理、Next.js 固有の API 呼び出しなどが混在した「アプリケーション中心」の構成でした。 この状態では Storybook 上でコンポーネントを再現するために様々なモックが必要となり、運用負荷が高まってしまいます。
そこで、Storybook を中心とした開発フローに切り替えるため Container / Presenter パターンにより、コンポーネントの構造を整理しました。
Container コンポーネントは、データフェッチ、副作用を伴う処理、Next.js 固有の API 呼び出しなどを一手に引き受けます。これらを Presenter に Props として注入することで、UI ロジックと実行環境の境界線を明確にしました。
'use client';
export function SampleButtonContainer() {
const router = useRouter();
const onClick = () => {
router.push('/some-page');
};
return <SampleButton onClick={onClick} />;
} 一方の Presenter コンポーネントは、受け取った Props を表示するだけの UI コンポーネントとして実装し、Storybook 上での表示に特化させました。
export function SampleButton({ onClick }: { onClick: () => void }) {
return <button onClick={onClick}>Button</button>;
} また、Presenter で子の Container をレンダリングしてしまうと、結局 Storybook 上で 子 Container の処理が実行されてしまうため、 子 Container は Presenter の props として注入する形 ( Compositionパターン ) にしています。 アプリケーションでは子 Container を Presenter に注入して利用し、Storybook では 子 Container に対応する Presenter を注入して利用するイメージです。
例えば、アプリケーションでは SampleCard に SampleButtonContainer を注入して利用し、Storybook では SampleCard に SampleButton を注入して利用する形になります。
// アプリケーション
export function SampleCardContainer() {
const { data } = await fetchData();
const { text } = data;
return (
<SampleCard
text={text}
button={<SampleButtonContainer />} // Container を注入
/>
);
}
// Storybook
export const Default: StoryObj<typeof SampleCard> = {
args: {
text: 'サンプルカード',
button: <SampleButton />, // Presenter を注入
},
}; 下の画像が実際の Storybook の画面です。表示パターンごとに Story を定義することで、UI の状態を網羅的に確認できるようになっています。
Playwright VRT 環境
実行環境の差異によるスナップショット差分を防ぐため、VRT の実行環境は Docker コンテナに統一しています。ブラウザ実行基盤には公式の Playwright Docker イメージを利用しています。
また、フォント差分による描画揺れを防ぐため、コンテナ内には Next.js アプリケーションと同一のフォント群を導入しています。
ディレクトリ構成
tests/
├── test-results/
│ ├── storybook-static/
│ ├── vrt-report/
│ ├── vrt-results/
│ └── vrt-snapshots/
└── vrt/
└── storybook-visual.spec.ts Dockerfile
FROM mcr.microsoft.com/playwright:v1.56.1
RUN apt-get update && \
apt-get install -y --no-install-recommends \
fontconfig \
fonts-noto \
fonts-noto-cjk \
fonts-noto-color-emoji \
fonts-dejavu-core && \
apt-get clean && \
apt-get purge -y fonts-wqy-zenhei && \
rm -rf /var/lib/apt/lists/*
RUN fc-cache -fv
RUN npm install @playwright/test@1.56.1 serve@14.2.5 --no-save compose.yaml
services:
vrt:
build:
context: .
dockerfile: docker/playwright/Dockerfile
working_dir: /work
ports:
- '9323:9323'
stdin_open: true
volumes:
- ./tests/test-results:/work/tests/test-results
- ./tests/vrt:/work/tests/vrt
- ./playwright.vrt.config.ts:/work/playwright.config.ts Storybook × Playwright VRT の実行
構築した Storybook の資産を活用し、UI 差分を機械的に検出するために Playwright による VRT を導入しています。
Storybook はビルド時に /index.json を生成し、定義されているすべての Story 情報(ID やタイトルなど)を出力します。
このファイルをテスト実行時に読み込むことで、各 Story の URL を動的に特定し、Playwright で網羅的にスクリーンショットを撮影・比較できるようにしています。
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from '@playwright/test';
interface StoryEntry {
id: string;
title: string;
name: string;
type: 'story' | 'docs' | string;
}
interface StoryIndexJson {
entries: Record<string, StoryEntry>;
}
const jsonPath = path.resolve(
__dirname,
'../test-results/storybook-static/index.json'
);
let stories: StoryIndexJson = { entries: {} };
if (fs.existsSync(jsonPath)) {
stories = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as StoryIndexJson;
}
const storyInfos = Object.values(stories.entries)
.filter((s) => s.type === 'story')
.map((s) => ({
id: s.id,
title: s.title,
name: s.name,
}));
for (const story of storyInfos) {
test(`VRT: ${story.title} / ${story.name}`, async ({ page }) => {
await page.goto(`iframe?id=${story.id}`, {
waitUntil: 'networkidle',
});
await page.waitForTimeout(300);
await page.evaluateHandle('document.fonts.ready');
await page.evaluate(() => {
document.documentElement.classList.add('dark');
});
await expect(page).toHaveScreenshot(`${story.id}.png`);
});
test(
`VRT: ${story.title} / ${story.name} (dark)`,
{ tag: ['@dark'] },
async ({ page }) => {
await page.goto(`iframe?id=${story.id}`, {
waitUntil: 'networkidle',
});
await page.waitForTimeout(300);
await page.evaluateHandle('document.fonts.ready');
await page.evaluate(() => {
document.documentElement.classList.add('dark');
});
await expect(page).toHaveScreenshot(`${story.id}-dark.png`);
}
);
} Playwright の設定ファイルは以下のようになっています。Storybook のビルド成果物をコンテナ内にマウントし、テスト実行前に Storybook を起動するようにしています。
import { defineConfig, devices } from '@playwright/test';
const snapshotSuffix = process.env.SNAPSHOT_PATH || '/local';
export default defineConfig({
testDir: './tests/vrt',
outputDir: `./tests/test-results/vrt-results`,
snapshotDir: `./tests/test-results/vrt-snapshots${snapshotSuffix}`,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never', outputFolder: 'tests/test-results/vrt-report' }],
['json', { outputFile: 'tests/test-results/test-results.json' }],
],
use: {
baseURL: 'http://localhost:6016',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
deviceScaleFactor: 4,
video: 'off',
headless: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 2 },
},
{
name: 'chromium-mobile',
use: { ...devices['Pixel 7'], deviceScaleFactor: 2 },
},
],
webServer: {
command: 'npx serve tests/test-results/storybook-static -l 6016',
port: 6016,
timeout: 120000,
},
}); また、日常の開発で利用する主なコマンドは以下の通りです。環境変数 SNAPSHOT_PATH を切り替えることで、ブランチごとに異なるスナップショットを管理できるよう工夫しています。
-
pnpm test:vrt: VRT を実行するコマンド -
pnpm test:vrt --grep="Components/Button": 特定のストーリーのみテスト -
pnpm test:vrt:update: スナップショットを更新するコマンド -
pnpm test:vrt:report: HTML レポートを起動するコマンド -
SNAPSHOT_PATH=/features/some-feature pnpm test:vrt: カスタムスナップショットパスで実行するコマンド
"scripts": {
"storybook": "storybook dev -p 6006 --no-open",
"storybook:build": "storybook build",
"test:vrt": "pnpm storybook:build -o tests/test-results/storybook-static && docker compose exec -e SNAPSHOT_PATH=$SNAPSHOT_PATH vrt npx playwright@1.56.1 test",
"test:vrt:update": "pnpm storybook:build -o tests/test-results/storybook-static && docker compose exec -e SNAPSHOT_PATH=$SNAPSHOT_PATH vrt npx playwright@1.56.1 test --update-snapshots",
"test:vrt:report": "docker compose exec vrt npx playwright@1.56.1 show-report tests/test-results/vrt-report --host 0.0.0.0 --port 9323"
} 実際の運用では、まずベースとなるブランチ (e.g. features/some-feature) で SNAPSHOT_PATH=/features/some-feature pnpm test:vrt:update を実行して基準となるスナップショットを作成します。
その後、開発を進める過程で SNAPSHOT_PATH=/features/some-feature pnpm test:vrt を実行し、意図しない差分が出ていないかを確認します。
差分がある場合は SNAPSHOT_PATH=/features/some-feature pnpm test:vrt:report で HTML レポートを確認し、修正や更新を判断する流れになります。
おわりに
本記事では、aumo Biz のレポート画面における Storybook 駆動開発の導入と、Playwright VRT を活用したテスト基盤の構築について紹介しました。 この取り組みにより、UI の意図しない変化を機械的に検出できるようになり、品質維持と運用負荷の抑制を両立しつつ、効率的にテスト資産を蓄積できる開発フローを実現しています。
本記事が何かの参考になれば幸いです。