目次
概要
共通コンポーネントのテスト方針を検討する中で、StorybookのTest Runnerを活用したVRT(Visual Regression Testing)とアクセシビリティテスト環境を構築してみました。
この記事は共通コンポーネントのテスト方針を決めるプロセスでStorybook基盤のテストを検証した内容をまとめたものです。全体のテスト方針と最終的なツール選定については [React] モノレポ環境での共通コンポーネントテスト方針 を参考にしてください。
Storybook Test Runnerは、Storybookに登録されたStoryを実際のブラウザ(Playwright)でレンダリングし、postVisitフックでスクリーンショットキャプチャやアクセシビリティ検査を行うことができます。
全体構造
Storybook Test Runner基盤のVRTの動作フローは以下の通りです。
- Storybookをビルドし静的サーバーで実行
- Test RunnerがPlaywrightブラウザを起動し各Storyを訪問
preVisitフックでaxe-coreをページに注入- Storyがレンダリングされると
play関数が実行(イベントテスト) postVisitフックでスクリーンショットをキャプチャしjest-image-snapshotで比較- アクセシビリティテストStory(
a-11-yを含むID)はaxe-playwrightで検査
Storybook Build → Static Server (port 6006)
→ Test Runner (Playwright)
→ preVisit: axe-core注入
→ Storyレンダリング + play関数実行
→ postVisit: スクリーンショットキャプチャ + スナップショット比較 + アクセシビリティ検査
環境設定
Storybook Test Runner基盤のVRT + アクセシビリティテストを行うには、まずテスト環境を設定する必要があります。
パッケージインストール
Storybook Test RunnerとVRT/アクセシビリティテストのために、以下のコマンドで必要なパッケージをインストールします。
yarn add -D @storybook/test-runner playwright jest-image-snapshot @types/jest-image-snapshot axe-playwright
インストールしたパッケージの役割は以下の通りです。
| パッケージ | 役割 |
|---|---|
@storybook/test-runner | StorybookのStoryをPlaywrightブラウザでテスト実行 |
playwright | ヘッドレスブラウザ自動化(Test Runnerのランタイム) |
jest-image-snapshot | スクリーンショット画像を既存スナップショットと比較 |
axe-playwright | Playwrightページでアクセシビリティ(a11y)検査を実行 |
Test Runner設定
今回の設定の核心ファイルです。.storybook/test-runner.tsにpostVisitフックを設定して、Storyレンダリング後にスクリーンショットキャプチャとアクセシビリティ検査を行います。
// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import { injectAxe, checkA11y } from 'axe-playwright';
import path from 'path';
const snapshotsDir = path.resolve(__dirname, '../__image_snapshots__');
const config: TestRunnerConfig = {
setup() {
// jest-image-snapshotのカスタムmatcherを登録
expect.extend({ toMatchImageSnapshot });
},
async preVisit(page) {
// 各Story訪問前にaxe-coreをページに注入
await injectAxe(page);
},
async postVisit(page, context) {
// test.stories.tsxファイルのStoryのみテスト対象とする
const storyIdParts = context.id.split('--');
const componentPath = storyIdParts[0];
if (!componentPath.endsWith('-test')) {
return;
}
// アクセシビリティテスト:Story IDに'a-11-y'が含まれる場合実行
if (context.id.includes('a-11-y')) {
try {
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: {
html: true,
},
});
} catch (error) {
console.error('Accessibility violations detected:', error);
}
}
// CSS、フォント、アニメーションの安定化待ち
await page.waitForTimeout(1000);
// リストアイテム等の非同期レンダリング待ち
await page
.waitForSelector('#storybook-root ul li', { timeout: 5000 })
.catch(() => {
// リストアイテムがないStory(空状態、エラー状態等)は無視
});
// スクリーンショットキャプチャ
const image = await page.screenshot({
fullPage: false,
animations: 'disabled',
});
// 既存スナップショットと比較
expect(image).toMatchImageSnapshot({
customSnapshotsDir: snapshotsDir,
customSnapshotIdentifier: context.id,
failureThreshold: 0.001,
failureThresholdType: 'percent',
storeReceivedOnFailure: true,
updatePassedSnapshot: process.env.UPDATE_SNAPSHOTS === 'true',
});
},
};
export default config;
ここでの核心ポイントは以下の通りです。
setup():jest-image-snapshotのカスタムmatcherを登録しますpreVisit(): 各Story訪問前にaxe-coreをページに注入します。これによりpostVisitでアクセシビリティ検査を行えますpostVisit(): Storyレンダリング後にスクリーンショットキャプチャ、スナップショット比較、アクセシビリティ検査を行います- テスト対象フィルタリング:
componentPath.endsWith('-test')で*.test.stories.tsxファイルのStoryのみテストします。これにより通常のStoryには影響しません - アクセシビリティテストトリガー: Story IDに
a-11-yが含まれている場合にアクセシビリティ検査を行います - スナップショット更新:
UPDATE_SNAPSHOTS=true環境変数でスナップショットを更新できます
.gitignore設定
VRTテストの基準(baseline)となるスナップショットはコミットする必要があります。ただし、テスト失敗時に生成されるdiff画像とreceived画像はGitから除外する必要があります。
# Visual regression test outputs (exclude diff and received images, but keep baseline snapshots)
__diff_output__/
__received_output__/
テストStory作成
ではStorybook Test Runner基盤のVRT + アクセシビリティテストのためのStoryを作成していきましょう。
ファイルコンベンション
テスト専用Storyは*.test.stories.tsxファイル名コンベンションを使用します。
コンポーネント名/
├── index.tsx # コンポーネント実装
├── index.stories.tsx # 通常のStory(デザイン/実装確認用)
└── sample.test.stories.tsx # テスト専用Story(VRT + イベント + アクセシビリティ)
以下の理由でテストStoryを通常のStoryと分離しました。
- 通常のStoryはデザインシステム文書化と開発中の確認用途で使用します
- テスト専用Storyはレンダリング/イベント/アクセシビリティテストに集中します
- Test Runner設定で
-testで終わるStoryのみフィルタリングするため、通常のStoryのスクリーンショットは生成されません
レンダリングテスト(Props変更に伴うView確認)
各Propsの組み合わせに対して個別のStoryを作成し、VRTを通じてViewを検証します。
// src/components/List/ExpandableTreeList/sample.test.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ExpandableTreeList } from '.';
const meta: Meta<typeof ExpandableTreeList> = {
component: ExpandableTreeList,
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// 空の状態レンダリング
export const RenderingEmptyState: Story = {
name: 'Rendering: 空の状態',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList fetchChildNodesFunction={createMockFetchFunction()} />
</div>
),
};
// ツリーノード表示
export const RenderingTopTreeNodes: Story = {
name: 'Rendering: ツリーノード表示',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
};
// カウントラベル表示
export const RenderingCountLabel: Story = {
name: 'Rendering: カウントラベル表示',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
countLabel="${count}件"
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
};
// 削除ボタン表示(onRemove Props提供時)
export const RenderingRemoveButtons: Story = {
name: 'Rendering: onRemove提供時に削除ボタン表示',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
onRemove={fn()}
/>
</div>
),
};
Storyごとに一つのPropsの組み合わせをテストするため、どのPropsがどのViewを生成するか明確に確認できます。
イベントテスト
Storybookのplay関数を使用してユーザーインタラクションをシミュレーションできます。また、play関数実行後にpostVisitフックでスクリーンショットとしてキャプチャされます。
import { expect } from '@storybook/jest';
import { userEvent, within } from '@storybook/testing-library';
// 子ノード表示テスト
export const EventDisplayChildNodes: Story = {
name: 'Event: フェッチ後に子ノード表示',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 展開ボタンクリック
const expandButton = canvas.getAllByRole('button')[0];
await userEvent.click(expandButton);
// 子ノードレンダリング待ち
await new Promise((resolve) => setTimeout(resolve, 100));
// 子ノード表示確認
expect(canvas.getByText('Child 1')).toBeInTheDocument();
expect(canvas.getByText('Child 2')).toBeInTheDocument();
},
};
// フェッチエラー表示テスト
export const EventFetchError: Story = {
name: 'Event: フェッチエラー表示',
render: () => {
const errorFetch = async () => {
throw new Error('Network error');
};
return (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={errorFetch}
/>
</div>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const expandButton = canvas.getAllByRole('button')[0];
await userEvent.click(expandButton);
await new Promise((resolve) => setTimeout(resolve, 100));
// エラーメッセージ表示確認
expect(canvas.getByText('エラーが発生しました')).toBeInTheDocument();
},
};
// Props変更時ノード更新テスト
export const RenderingPropsChange: Story = {
name: 'Rendering: Props変更時にノード更新',
render: () => {
const TestComponent = () => {
const [nodes, setNodes] = useState(sampleTopTreeNodes);
return (
<div style={{ height: '25rem' }}>
<button
onClick={() =>
setNodes([
{ value: 'new', label: 'New Node', hasChildren: false },
])
}
>
ノード変更
</button>
<ExpandableTreeList
topTreeNodes={nodes}
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
);
};
return <TestComponent />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText('Node 1')).toBeInTheDocument();
await userEvent.click(canvas.getByText('ノード変更'));
expect(canvas.queryByText('Node 1')).not.toBeInTheDocument();
expect(canvas.getByText('New Node')).toBeInTheDocument();
},
};
アクセシビリティテスト
Storybookでもaxe-playwrightを活用してアクセシビリティ検査を行えます。以下のようにStory名にA11yを含めると、Story IDにa-11-yが含まれ、Test Runnerが自動的にアクセシビリティ検査を行います。
export const A11yTest: Story = {
name: 'Accessibility: アクセシビリティテスト',
tags: ['a11y-test'],
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
sectionLabel="アクセシビリティテスト"
countLabel="${count}件"
fetchChildNodesFunction={createMockFetchFunction()}
onRemove={fn()}
/>
</div>
),
parameters: {
chromatic: { disableSnapshot: true },
a11y: {
config: {
rules: [
{
id: 'button-name',
enabled: false, // TODO: 展開/削除ボタンにaria-labelを追加
},
],
},
},
},
};
アクセシビリティ検査はaxe-playwrightのcheckA11yを通じて行われ、WCAG違反事項があるとテストが失敗します。
実行スクリプト
このように設定したStorybook Test Runner基盤のVRTテストは、以下のスクリプトで実行できます。
// package.json
{
"scripts": {
"test:visual:ci": "yarn build-storybook && npx concurrently -k -s first -n \"Server,Test\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"yarn playwright install && npx wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"",
"test:visual:update": "UPDATE_SNAPSHOTS=true yarn build-storybook && npx concurrently -k -s first -n \"Server,Test\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"yarn playwright install && npx wait-on tcp:127.0.0.1:6006 && UPDATE_SNAPSHOTS=true test-storybook --url http://127.0.0.1:6006 --updateSnapshot\"",
"test:a11y": "yarn build-storybook && npx concurrently -k -s first -n \"Server,Test\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"yarn playwright install && npx wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\""
}
}
スクリプトの動作順序は以下の通りです。
yarn build-storybook: Storybookを静的ファイルにビルドhttp-server storybook-static --port 6006: ビルドしたStorybookをローカルサーバーで起動wait-on tcp:127.0.0.1:6006: サーバー準備完了まで待機test-storybook --url http://127.0.0.1:6006: Test Runner実行
各スクリプトの用途は以下の通りです。
test:visual:ci: CI環境でVRT + アクセシビリティテスト実行test:visual:update: 基準スナップショット更新(コンポーネントを意図的に変更した場合)test:a11y: アクセシビリティテスト実行
ディレクトリ構造
今回の記事で生成または修正するファイルのディレクトリ構造は以下の通りです。
プロジェクトルート/
├── .storybook/
│ ├── main.ts # Storybook設定(既存ファイル)
│ └── test-runner.ts # Test Runner設定(新規作成)
├── __image_snapshots__/ # 基準スナップショット画像(自動生成、Gitコミット対象)
│ ├── component-name-test--story-name.png
│ └── ...
├── __diff_output__/ # テスト失敗時のdiff画像(.gitignore対象)
├── __received_output__/ # テスト失敗時のreceived画像(.gitignore対象)
├── .gitignore # diff/receivedディレクトリ除外追加(修正)
├── package.json # 実行スクリプト追加(修正)
└── src/
└── components/
└── List/
└── ExpandableTreeList/
├── index.tsx
├── index.stories.tsx # 通常のStory(既存ファイル)
└── sample.test.stories.tsx # テスト専用Story(新規作成)
まとめ
この記事ではStorybook Test Runnerを活用したVRT + アクセシビリティテスト環境の構築方法を紹介しました。
| 項目 | 内容 |
|---|---|
| レンダリング/VRT | Storybook Test Runner + jest-image-snapshot |
| イベントテスト | Storybook play関数でインタラクションシミュレーション |
| アクセシビリティテスト | axe-playwrightでWCAG違反検査 |
| テスト分離 | *.test.stories.tsxファイルコンベンションでテストStory分離 |
| スナップショット管理 | __image_snapshots__/に基準画像保存、UPDATE_SNAPSHOTSで更新 |
Storybookを活用した方式は、別途のレンダリングユーティリティなしにStoryをそのままテストに活用できるという利点があります。ただし、プロダクトの単体テスト(Jest/Vitest)と作成方法が異なるため、チーム内で2つのテストパターンを管理する必要があり、Storyファイルが膨大になる可能性がある点は考慮が必要です。
Jest + Puppeteerを活用したVRT構築方法が気になる方は [React] Jest + PuppeteerでVRT構築 を参考にしてください。
私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!
アプリ広報
Dekuが開発したアプリを使ってみてください。Dekuが開発したアプリはFlutterで開発されています。興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。