Testing Design Systems with Storybook: Component Libraries at Scale
Design systems are the foundation of consistent UI. A broken button component or incorrect token in your design system affects every product that consumes it. Testing a design system requires more rigor than testing a single application — components must work correctly across all their states, variants, themes, and accessibility requirements.
Storybook is the standard tool for building and testing design systems. This guide covers how to set up comprehensive testing for a component library: interaction tests, visual regression, accessibility, design token validation, and multi-theme coverage.
Why Design Systems Need Different Testing
Application components are tested in context — you test the login page, not just the button inside it. Design system components are tested in isolation because their consumers are unknown. This creates specific challenges:
- Every variant must be explicitly tested — no user flow will naturally exercise all 12 button variants
- Accessibility is non-negotiable — a design system that fails WCAG breaks every app that uses it
- Visual consistency must be verified — pixel drift across themes or breakpoints is a design system bug
- Tokens must match — if
--color-primarychanges, every component that uses it needs visual verification
Storybook addresses all of these by forcing you to enumerate component states as stories.
Project Structure
A typical design system Storybook setup:
design-system/
├── src/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.stories.tsx
│ │ └── Button.test.tsx
│ ├── Input/
│ ├── Modal/
│ └── tokens/
│ ├── colors.ts
│ └── spacing.ts
├── .storybook/
│ ├── main.ts
│ ├── preview.ts
│ └── themes.ts
└── package.jsonKeep stories co-located with components. This makes it obvious when a new variant is added but no story exists for it.
Writing Comprehensive Stories
For a design system component, write stories for every meaningful state:
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'destructive', 'ghost', 'link'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
// Variants
export const Primary: Story = { args: { variant: 'primary', children: 'Save changes' } };
export const Secondary: Story = { args: { variant: 'secondary', children: 'Cancel' } };
export const Destructive: Story = { args: { variant: 'destructive', children: 'Delete account' } };
export const Ghost: Story = { args: { variant: 'ghost', children: 'Settings' } };
// Sizes
export const Small: Story = { args: { size: 'sm', children: 'Small button' } };
export const Large: Story = { args: { size: 'lg', children: 'Large button' } };
// States
export const Disabled: Story = { args: { disabled: true, children: 'Cannot click' } };
export const Loading: Story = { args: { loading: true, children: 'Saving...' } };
// With icons
export const WithLeadingIcon: Story = {
args: { icon: 'arrow-left', iconPosition: 'start', children: 'Back' },
};
export const IconOnly: Story = {
args: { icon: 'close', 'aria-label': 'Close dialog' },
};Each story is one screenshot for visual regression and one test case for interaction testing. The more explicit your stories, the more coverage you get automatically.
Interaction Tests for Design System Components
Design system components need interaction tests for behavioral contracts — things the component promises to do when used.
import { expect, userEvent, within, fn } from '@storybook/test';
export const Clickable: Story = {
args: {
onClick: fn(),
children: 'Click me',
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).toHaveBeenCalledOnce();
},
};
export const NotClickableWhenDisabled: Story = {
args: {
disabled: true,
onClick: fn(),
children: 'Cannot click',
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).not.toHaveBeenCalled();
},
};These tests document behavioral contracts. Any contributor knows: buttons must call onClick when clicked and must not call it when disabled. If a change breaks this, the test catches it.
Testing Compound Components
Design systems often have compound components — components composed of multiple child components:
// Dropdown.stories.tsx
export const SelectsOption: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Open the dropdown
await userEvent.click(canvas.getByRole('button', { name: 'Select country' }));
// Wait for options to appear
const listbox = await canvas.findByRole('listbox');
const options = within(listbox);
// Select an option
await userEvent.click(options.getByRole('option', { name: 'Germany' }));
// Verify selection
await expect(canvas.getByRole('button')).toHaveTextContent('Germany');
await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument();
},
};Accessibility Testing
Design systems must meet WCAG standards. Use the Storybook accessibility addon to catch violations automatically.
Install
npm install --save-dev @storybook/addon-a11yAdd to .storybook/main.ts:
export default {
addons: ['@storybook/addon-a11y'],
};The A11y panel now shows violations, passes, and incomplete checks for each story using axe-core.
Asserting in play Functions
For CI, assert accessibility in play functions:
import { checkA11y } from 'axe-playwright';
export const AccessibleButton: Story = {
play: async ({ canvasElement }) => {
// Interaction tests
const canvas = within(canvasElement);
await expect(canvas.getByRole('button')).toBeVisible();
// Accessibility check (with test-runner + axe integration)
await checkA11y(canvasElement, null, {
detailedReport: true,
detailedReportOptions: { html: true },
});
},
};For automatic a11y checks on every story without play functions, configure the test runner:
// .storybook/test-runner.ts
import { checkA11y, injectAxe } from 'axe-playwright';
module.exports = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page) {
await checkA11y(page, '#storybook-root', {
detailedReport: true,
});
},
};This runs axe on every story automatically — no play function needed for basic accessibility coverage.
Multi-Theme Testing
Design systems often support multiple themes (light/dark, brand themes). Test all of them.
Decorator-based Theme Setup
// .storybook/preview.ts
import { ThemeProvider } from '../src/ThemeProvider';
export const decorators = [
(Story, context) => {
const theme = context.globals.theme || 'light';
return (
<ThemeProvider theme={theme}>
<Story />
</ThemeProvider>
);
},
];
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'circlehollow',
items: ['light', 'dark', 'high-contrast'],
},
},
};Chromatic Modes for Automated Multi-Theme Coverage
// .storybook/modes.ts
export const allModes = {
light: { theme: 'light' },
dark: { theme: 'dark' },
'high-contrast': { theme: 'high-contrast' },
};Apply to the entire design system:
// .storybook/preview.ts
export const parameters = {
chromatic: {
modes: allModes,
},
};Every story now gets three screenshots — one per theme. If the dark mode button suddenly has wrong contrast, Chromatic catches it.
Design Token Validation
Design tokens (colors, spacing, typography) are the source of truth. Test that components use the correct tokens.
Snapshot Testing Token Values
// tokens.test.ts
import { colors, spacing } from './tokens';
test('primary color is accessible on white', () => {
// Contrast ratio check using WCAG formula
const contrastRatio = calculateContrast(colors.primary, '#ffffff');
expect(contrastRatio).toBeGreaterThanOrEqual(4.5); // WCAG AA
});
test('spacing scale follows 4px grid', () => {
Object.values(spacing).forEach((value) => {
const px = parseInt(value, 10);
expect(px % 4).toBe(0); // Must be multiple of 4
});
});CSS Custom Property Verification
If tokens are CSS custom properties, verify they're applied correctly with computed styles:
export const UsesTokenColors: Story = {
play: async ({ canvasElement }) => {
const button = canvasElement.querySelector('[data-component="button"]')!;
const styles = getComputedStyle(button);
// Verify token is applied (not a hardcoded value)
expect(styles.backgroundColor).toBe('rgb(59, 130, 246)'); // --color-primary value
},
};Storybook Docs for Design System Documentation
Design systems need documentation alongside tests. Storybook's Docs addon generates interactive documentation from stories and JSDoc:
npm install --save-dev @storybook/addon-docsConfigure MDX stories for richer documentation:
{/* Button.mdx */}
import { Canvas, Controls, Meta } from '@storybook/blocks';
import * as ButtonStories from './Button.stories';
<Meta of={ButtonStories} />
# Button
Use buttons for primary actions. The `variant` prop controls visual weight.
<Canvas of={ButtonStories.Primary} />
## All variants
<Canvas of={ButtonStories.Destructive} />
## Props
<Controls />This gives your design system consumers interactive documentation that's always in sync with the actual component code.
CI Pipeline for Design Systems
A complete CI pipeline for a design system:
name: Design System CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npm ci
# Unit tests for utilities and tokens
- run: npm test
# Build Storybook
- run: npm run build-storybook
# Run interaction tests
- name: Interaction tests
run: |
npx concurrently -k -s first \
"npx http-server storybook-static -p 6006 --silent" \
"npx wait-on tcp:6006 && npx test-storybook --url http://localhost:6006"
# Visual regression + a11y via Chromatic
- name: Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: trueThis catches:
- Broken interactions (test runner)
- Visual regressions (Chromatic)
- Accessibility violations (axe in test runner)
- Token issues (unit tests)
Publishing a Tested Design System
When your CI is green, publish with confidence:
# Bump version
npm version patch <span class="hljs-comment"># or minor/major
<span class="hljs-comment"># Publish
npm publish
<span class="hljs-comment"># Update Storybook docs
npm run deploy-storybookConsumers can see exactly what changed by browsing the published Storybook — stories for every variant, documented behavior, interaction demos. No guessing what the new version looks like.
Testing a design system thoroughly is the difference between a library your team trusts and one they're afraid to update. Storybook makes that testing systematic: write stories, get coverage. The more states you enumerate, the more regressions you catch for free.