Table of Contents
Overview
While reviewing testing strategies for shared components, we built a VRT (Visual Regression Testing) and accessibility testing environment using Storybook’s Test Runner.
This article documents the process of validating Storybook-based testing as part of establishing a testing strategy for shared components. For the overall testing strategy and final tool selection, see [React] Testing Strategy for Shared Components in a Monorepo.
Storybook Test Runner renders Stories registered in Storybook in a real browser (Playwright) and can perform screenshot capture and accessibility checks in the postVisit hook.
Overall Architecture
The workflow of Storybook Test Runner-based VRT is as follows:
- Build Storybook and run it as a static server
- Test Runner launches a Playwright browser and visits each Story
preVisithook injects axe-core into the page- When a Story renders, the
playfunction executes (event testing) postVisithook captures a screenshot and compares it withjest-image-snapshot- Accessibility test Stories (IDs containing
a-11-y) are checked withaxe-playwright
Storybook Build → Static Server (port 6006)
→ Test Runner (Playwright)
→ preVisit: inject axe-core
→ Story rendering + play function execution
→ postVisit: screenshot capture + snapshot comparison + accessibility check
Environment Setup
To set up Storybook Test Runner-based VRT + accessibility testing, you first need to configure the test environment.
Package Installation
Install the required packages for Storybook Test Runner and VRT/accessibility testing:
yarn add -D @storybook/test-runner playwright jest-image-snapshot @types/jest-image-snapshot axe-playwright
The role of each package is as follows:
| Package | Role |
|---|---|
@storybook/test-runner | Runs Storybook Stories as tests in a Playwright browser |
playwright | Headless browser automation (Test Runner runtime) |
jest-image-snapshot | Compares screenshot images against existing snapshots |
axe-playwright | Performs accessibility (a11y) checks on Playwright pages |
Test Runner Configuration
This is the core file of this setup. Configure the postVisit hook in .storybook/test-runner.ts to perform screenshot capture and accessibility checks after Story rendering.
// .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() {
// Register jest-image-snapshot custom matcher
expect.extend({ toMatchImageSnapshot });
},
async preVisit(page) {
// Inject axe-core into the page before each Story visit
await injectAxe(page);
},
async postVisit(page, context) {
// Only test Stories from test.stories.tsx files
const storyIdParts = context.id.split('--');
const componentPath = storyIdParts[0];
if (!componentPath.endsWith('-test')) {
return;
}
// Accessibility test: run when Story ID contains '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);
}
}
// Wait for CSS, fonts, and animations to stabilize
await page.waitForTimeout(1000);
// Wait for async rendering such as list items
await page
.waitForSelector('#storybook-root ul li', { timeout: 5000 })
.catch(() => {
// Some Stories may not have list items (empty state, error states, etc.)
});
// Capture screenshot
const image = await page.screenshot({
fullPage: false,
animations: 'disabled',
});
// Compare with existing snapshot
expect(image).toMatchImageSnapshot({
customSnapshotsDir: snapshotsDir,
customSnapshotIdentifier: context.id,
failureThreshold: 0.001,
failureThresholdType: 'percent',
storeReceivedOnFailure: true,
updatePassedSnapshot: process.env.UPDATE_SNAPSHOTS === 'true',
});
},
};
export default config;
Key points:
setup(): Registers thejest-image-snapshotcustom matcherpreVisit(): Injectsaxe-coreinto the page before each Story visit, enabling accessibility checks inpostVisitpostVisit(): Performs screenshot capture, snapshot comparison, and accessibility checks after Story rendering- Test target filtering: Only tests Stories from
*.test.stories.tsxfiles usingcomponentPath.endsWith('-test'), so regular Stories are unaffected - Accessibility test trigger: Runs accessibility checks when the Story ID contains
a-11-y - Snapshot update: Snapshots can be updated using the
UPDATE_SNAPSHOTS=trueenvironment variable
.gitignore Configuration
Baseline snapshots for VRT tests should be committed. However, diff and received images generated on test failure should be excluded from Git.
# Visual regression test outputs (exclude diff and received images, but keep baseline snapshots)
__diff_output__/
__received_output__/
Writing Test Stories
Now let’s write Stories for Storybook Test Runner-based VRT + accessibility testing.
File Convention
Test-dedicated Stories use the *.test.stories.tsx filename convention.
ComponentName/
├── index.tsx # Component implementation
├── index.stories.tsx # Regular Stories (for design/implementation review)
└── sample.test.stories.tsx # Test-dedicated Stories (VRT + event + accessibility)
Reasons for separating test Stories from regular Stories:
- Regular Stories are used for design system documentation and development review
- Test-dedicated Stories focus on rendering/event/accessibility testing
- Since the Test Runner configuration filters only Stories ending with
-test, screenshots are not generated for regular Stories
Rendering Tests (Verifying View with Props Changes)
Write separate Stories for each Props combination to verify the View through VRT.
// 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>;
// Empty state rendering
export const RenderingEmptyState: Story = {
name: 'Rendering: Empty State',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList fetchChildNodesFunction={createMockFetchFunction()} />
</div>
),
};
// Tree node display
export const RenderingTopTreeNodes: Story = {
name: 'Rendering: Display Tree Nodes',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
};
// Count label display
export const RenderingCountLabel: Story = {
name: 'Rendering: Count Label Display',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
countLabel="${count} items"
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
};
// Remove button display (when onRemove prop is provided)
export const RenderingRemoveButtons: Story = {
name: 'Rendering: Remove Buttons with onRemove',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
onRemove={fn()}
/>
</div>
),
};
Each Story tests one Props combination, making it clear which Props produce which View.
Event Tests
You can simulate user interactions using Storybook’s play function. The state after play function execution is captured as a screenshot in the postVisit hook.
import { expect } from '@storybook/jest';
import { userEvent, within } from '@storybook/testing-library';
// Child node display test
export const EventDisplayChildNodes: Story = {
name: 'Event: Display Child Nodes After Fetch',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Click expand button
const expandButton = canvas.getAllByRole('button')[0];
await userEvent.click(expandButton);
// Wait for child node rendering
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify child nodes are displayed
expect(canvas.getByText('Child 1')).toBeInTheDocument();
expect(canvas.getByText('Child 2')).toBeInTheDocument();
},
};
// Fetch error display test
export const EventFetchError: Story = {
name: 'Event: Fetch Error Display',
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));
// Verify error message is displayed
expect(canvas.getByText('An error occurred')).toBeInTheDocument();
},
};
// Props change node update test
export const RenderingPropsChange: Story = {
name: 'Rendering: Node Update on Props Change',
render: () => {
const TestComponent = () => {
const [nodes, setNodes] = useState(sampleTopTreeNodes);
return (
<div style={{ height: '25rem' }}>
<button
onClick={() =>
setNodes([
{ value: 'new', label: 'New Node', hasChildren: false },
])
}
>
Change Nodes
</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('Change Nodes'));
expect(canvas.queryByText('Node 1')).not.toBeInTheDocument();
expect(canvas.getByText('New Node')).toBeInTheDocument();
},
};
Accessibility Tests
You can also use axe-playwright in Storybook to perform accessibility checks. By including A11y in the Story name, the Story ID will contain a-11-y, and the Test Runner will automatically perform accessibility checks.
export const A11yTest: Story = {
name: 'Accessibility: Accessibility Test',
tags: ['a11y-test'],
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
sectionLabel="Accessibility Test"
countLabel="${count} items"
fetchChildNodesFunction={createMockFetchFunction()}
onRemove={fn()}
/>
</div>
),
parameters: {
chromatic: { disableSnapshot: true },
a11y: {
config: {
rules: [
{
id: 'button-name',
enabled: false, // TODO: Add aria-labels to expand/remove buttons
},
],
},
},
},
};
Accessibility checks are performed through axe-playwright’s checkA11y, and the test fails if WCAG violations are found.
Run Scripts
The Storybook Test Runner-based VRT tests configured above can be run with the following scripts:
// 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\""
}
}
The execution flow is:
yarn build-storybook: Build Storybook into static fileshttp-server storybook-static --port 6006: Run the built Storybook as a local serverwait-on tcp:127.0.0.1:6006: Wait until the server is readytest-storybook --url http://127.0.0.1:6006: Run the Test Runner
Script purposes:
test:visual:ci: Run VRT + accessibility tests in CI environmenttest:visual:update: Update baseline snapshots (when components are intentionally changed)test:a11y: Run accessibility tests
Directory Structure
The directory structure of files created or modified in this article:
Project Root/
├── .storybook/
│ ├── main.ts # Storybook config (existing file)
│ └── test-runner.ts # Test Runner config (newly created)
├── __image_snapshots__/ # Baseline snapshot images (auto-generated, committed to Git)
│ ├── component-name-test--story-name.png
│ └── ...
├── __diff_output__/ # Diff images on test failure (.gitignore target)
├── __received_output__/ # Received images on test failure (.gitignore target)
├── .gitignore # Add diff/received directory exclusion (modified)
├── package.json # Add run scripts (modified)
└── src/
└── components/
└── List/
└── ExpandableTreeList/
├── index.tsx
├── index.stories.tsx # Regular Stories (existing file)
└── sample.test.stories.tsx # Test-dedicated Stories (newly created)
Summary
This article introduced how to build a VRT + accessibility testing environment using Storybook Test Runner.
| Item | Details |
|---|---|
| Rendering/VRT | Storybook Test Runner + jest-image-snapshot |
| Event Testing | Simulate interactions with Storybook play function |
| Accessibility Testing | WCAG violation checks with axe-playwright |
| Test Separation | Separate test Stories with *.test.stories.tsx file convention |
| Snapshot Management | Store baseline images in __image_snapshots__/, update with UPDATE_SNAPSHOTS |
The Storybook-based approach has the advantage of using Stories directly for testing without a separate rendering utility. However, it differs from product-level unit testing (Jest/Vitest) approaches, requiring the team to manage two testing patterns, and Story files can become unwieldy.
For those interested in building VRT with Jest + Puppeteer, see [React] Building VRT with Jest + Puppeteer.
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.