Component-Level Visual Testing in Storybook
Why Component Testing Catches Issues Earlier
Storybook enables visual testing at the component level, catching visual regressions before they compound into page-level issues. A button component tested in isolation across all its states (default, hover, focus, disabled, loading) catches styling bugs at the source -- not after they have propagated into 20 different pages.
Storybook + Accessibility Testing
Configure Storybook to run accessibility checks on every story:
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
a11y: {
// axe-core configuration for all stories
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'image-alt', enabled: true },
{ id: 'label', enabled: true },
{ id: 'button-name', enabled: true },
],
},
},
},
};
export default preview;
This configuration adds an "Accessibility" panel to every story in Storybook. Developers see violations in real-time while building components.
Writing Stories for Visual Testing
DataTable Component
// DataTable.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { DataTable } from './DataTable';
import { within, userEvent } from '@storybook/testing-library';
const meta: Meta<typeof DataTable> = {
title: 'Components/DataTable',
component: DataTable,
parameters: {
chromatic: {
viewports: [375, 768, 1200],
},
},
};
export default meta;
export const WithData: StoryObj<typeof DataTable> = {
args: {
columns: [
{ key: 'name', header: 'Product Name', sortable: true },
{ key: 'price', header: 'Price', sortable: true },
{ key: 'stock', header: 'In Stock', sortable: false },
],
data: [
{ name: 'Widget A', price: '$29.99', stock: 'Yes' },
{ name: 'Widget B', price: '$49.99', stock: 'No' },
{ name: 'Widget C', price: '$19.99', stock: 'Yes' },
],
},
};
export const Empty: StoryObj<typeof DataTable> = {
args: {
columns: [{ key: 'name', header: 'Product Name' }],
data: [],
},
};
export const Loading: StoryObj<typeof DataTable> = {
args: {
columns: [{ key: 'name', header: 'Product Name' }],
data: [],
loading: true,
},
};
export const WithError: StoryObj<typeof DataTable> = {
args: {
columns: [{ key: 'name', header: 'Product Name' }],
data: [],
error: 'Failed to load product data. Please try again.',
},
};
// Interaction testing: verify sort behavior
export const SortedByPrice: StoryObj<typeof DataTable> = {
...WithData,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Click the Price column header to sort
await userEvent.click(canvas.getByText('Price'));
// Visual snapshot captures the sorted state
},
};
Testing All Component States
A visual regression suite for a single component should cover all meaningful states:
// Button.stories.tsx -- comprehensive state coverage
export const AllStates: StoryObj<typeof Button> = {
render: () => (
<div style={{ display: 'grid', gap: '1rem', maxWidth: '400px' }}>
<h3>Primary</h3>
<Button variant="primary">Default</Button>
<Button variant="primary" disabled>Disabled</Button>
<Button variant="primary" loading>Loading</Button>
<h3>Secondary</h3>
<Button variant="secondary">Default</Button>
<Button variant="secondary" disabled>Disabled</Button>
<h3>Danger</h3>
<Button variant="danger">Default</Button>
<Button variant="danger" disabled>Disabled</Button>
<h3>Sizes</h3>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<h3>With Icons</h3>
<Button icon="plus">Add Item</Button>
<Button icon="trash" variant="danger">Delete</Button>
</div>
),
parameters: {
chromatic: {
viewports: [375, 1200],
},
},
};
// Focus state -- important for accessibility visual testing
export const FocusState: StoryObj<typeof Button> = {
args: { variant: 'primary', children: 'Focused Button' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await button.focus();
// Snapshot captures the focus ring
},
};
Form Component Accessibility Stories
// TextField.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { TextField } from './TextField';
const meta: Meta<typeof TextField> = {
title: 'Components/TextField',
component: TextField,
};
export default meta;
export const Default: StoryObj<typeof TextField> = {
args: {
label: 'Email address',
placeholder: 'you@example.com',
type: 'email',
},
};
export const WithError: StoryObj<typeof TextField> = {
args: {
label: 'Email address',
value: 'not-an-email',
error: 'Please enter a valid email address',
},
};
export const WithHelperText: StoryObj<typeof TextField> = {
args: {
label: 'Password',
type: 'password',
helperText: 'Must be at least 8 characters with one number',
},
};
export const Required: StoryObj<typeof TextField> = {
args: {
label: 'Full Name',
required: true,
},
};
export const Disabled: StoryObj<typeof TextField> = {
args: {
label: 'Username',
value: 'johndoe',
disabled: true,
},
};
Automated Storybook Testing in CI
With Chromatic
- name: Build and test Storybook
run: |
npx storybook build -o storybook-static
npx chromatic --project-token=${{ secrets.CHROMATIC_TOKEN }} \
--exit-once-uploaded \
--only-changed
With Playwright
// tests/storybook/visual.spec.ts
import { test, expect } from '@playwright/test';
const STORYBOOK_URL = 'http://localhost:6006';
// Generate tests for each story
const stories = [
'components-button--primary',
'components-button--disabled',
'components-button--loading',
'components-datatable--with-data',
'components-datatable--empty',
'components-textfield--with-error',
];
for (const story of stories) {
test(`visual: ${story}`, async ({ page }) => {
await page.goto(`${STORYBOOK_URL}/iframe.html?id=${story}`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`${story}.png`, {
maxDiffPixelRatio: 0.01,
animations: 'disabled',
});
});
test(`a11y: ${story}`, async ({ page }) => {
await page.goto(`${STORYBOOK_URL}/iframe.html?id=${story}`);
await page.waitForLoadState('networkidle');
const AxeBuilder = (await import('@axe-core/playwright')).default;
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
}
Benefits of Component-Level Testing
| Benefit | Page-Level Testing | Component-Level Testing |
|---|---|---|
| Speed | Slower (full page render) | Faster (isolated component) |
| Isolation | Side effects from other components | Pure component behavior |
| State coverage | Hard to reach all states | Easy to render each state |
| Debugging | "Something changed on the page" | "This specific component changed" |
| Maintenance | Tests break when layout changes | Tests stable if component API unchanged |
Component-level visual testing in Storybook catches issues earlier than page-level screenshots, at a fraction of the cost. It is the visual testing equivalent of unit tests vs integration tests -- do both, but start with components.