Running Storybook Stories as Automated Tests with @storybook/test-runner
Every team that uses Storybook eventually faces the same question: we have hundreds of stories documenting our components, but how do we make sure none of them are broken without manually clicking through all of them? The @storybook/test-runner answers that question. It turns every story into an automated test, runs them headlessly in Chromium via Playwright, and integrates cleanly with your existing CI pipeline.
This guide covers the full picture — from initial setup through advanced CI configuration, handling authenticated stories, and debugging failures when they happen.
What the Test Runner Actually Does
The test runner transforms stories into Jest test cases. For each story, it:
- Opens the story in a headless Chromium browser via Playwright
- Waits for the story to render without errors
- Executes any
playfunction attached to the story - Asserts the play function completed without thrown errors
This means you get two levels of testing for free: smoke testing (the story renders at all) and interaction testing (play functions exercise real user flows). Any story that throws during render or during its play function becomes a failing test.
Installation and Basic Setup
npm install --save-dev @storybook/test-runnerAdd a script to your package.json:
{
"scripts": {
"test-storybook": "test-storybook"
}
}With Storybook running on port 6006 (the default), you can immediately run:
npm run test-storybookThe test runner discovers your stories by reading .storybook/main.js — the same stories glob you already have configured. No additional configuration is needed to get started.
Running Against a Built Storybook
In CI, you typically don't want to run the Storybook dev server. Instead, build Storybook first and serve the static output:
npm run build-storybook
npx serve storybook-static &
npx wait-on http://localhost:3000
npm run test-storybook -- --url http://localhost:3000The wait-on utility is important — the test runner will fail immediately if it tries to connect before the server is ready. Install it with npm install --save-dev wait-on.
For the --url flag, always pass the full URL including protocol. The test runner uses this as the base URL for all story navigation.
Jest Configuration
The test runner is built on Jest and Playwright, and you can customize it with a configuration file at .storybook/test-runner.js:
// .storybook/test-runner.js
const { getStoryContext } = require('@storybook/test-runner');
module.exports = {
async preVisit(page, context) {
// Runs before each story is visited
// Useful for setting cookies, localStorage, or auth tokens
},
async postVisit(page, context) {
// Runs after each story renders and play() completes
// Perfect for additional assertions
const storyContext = await getStoryContext(page, context);
// Example: run accessibility checks on every story
// (requires axe-playwright)
if (!storyContext.parameters?.a11y?.disable) {
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: { html: true },
});
}
},
};The getStoryContext function gives you access to the story's parameters, args, and other metadata. This makes it possible to write hooks that behave differently based on per-story configuration.
Smoke Testing All Stories Without Play Functions
Even stories without play functions are valuable to test. A smoke test catches:
- Import errors in the component file
- Runtime errors during initial render
- Missing required props that cause crashes
- Network requests that fail and throw uncaught errors
The test runner handles this automatically — every story becomes a test case that verifies the story renders without errors. For a Storybook with 200 stories, you get 200 smoke tests for free.
To see which stories are being tested, run with --verbose:
npm run test-storybook -- --verbosePlay Functions Run Headlessly
Play functions written with the @storybook/test utilities work identically in the test runner. Here is a story with a play function that tests a login form:
// LoginForm.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { LoginForm } from './LoginForm';
const meta: Meta<typeof LoginForm> = {
component: LoginForm,
};
export default meta;
type Story = StoryObj<typeof LoginForm>;
export const SuccessfulLogin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emailInput = canvas.getByLabelText('Email');
const passwordInput = canvas.getByLabelText('Password');
const submitButton = canvas.getByRole('button', { name: 'Sign in' });
await userEvent.type(emailInput, 'user@example.com');
await userEvent.type(passwordInput, 'password123');
await userEvent.click(submitButton);
await expect(canvas.getByText('Welcome back!')).toBeInTheDocument();
},
};
export const ValidationErrors: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Submit without filling fields
await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));
await expect(canvas.getByText('Email is required')).toBeInTheDocument();
await expect(canvas.getByText('Password is required')).toBeInTheDocument();
},
};When the test runner encounters SuccessfulLogin, it opens the story, waits for the component to mount, then executes the play function exactly as it would run in the browser UI. Failures are reported with the same error messages you'd see in the Storybook UI.
Handling Authentication in Stories
Many components require authentication context to render correctly. There are several patterns for handling this.
Pattern 1: Auth via story decorators
Wrap authenticated stories in a decorator that provides the auth context:
// MyProtectedComponent.stories.ts
export const AuthenticatedView: Story = {
decorators: [
(Story) => (
<AuthProvider value={{ user: mockUser, isAuthenticated: true }}>
<Story />
</AuthProvider>
),
],
};This is the cleanest approach — the story is self-contained and the test runner sees the same authenticated state as the browser UI.
Pattern 2: Auth tokens via test-runner hooks
If your stories rely on cookies or localStorage for auth, use the preVisit hook:
// .storybook/test-runner.js
module.exports = {
async preVisit(page, context) {
// Set a session cookie before each story loads
await page.context().addCookies([
{
name: 'session_token',
value: process.env.TEST_SESSION_TOKEN,
domain: 'localhost',
path: '/',
},
]);
// Or set localStorage
await page.addInitScript(() => {
window.localStorage.setItem(
'auth_token',
process.env.TEST_AUTH_TOKEN
);
});
},
};Store sensitive values in environment variables and never commit them to the repository.
Pattern 3: MSW handlers for auth endpoints
Combined with the MSW addon, you can mock authentication API calls per story. This is covered in the MSW addon guide, but the short version is that each story can define its own network handlers that intercept API calls — no real backend required.
GitHub Actions CI Integration
Here is a complete GitHub Actions workflow that builds Storybook, serves it, and runs the test runner:
# .github/workflows/storybook-tests.yml
name: Storybook Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-storybook:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build Storybook
run: npm run build-storybook -- --quiet
- name: Serve Storybook and run tests
run: |
npx serve storybook-static --port 6006 &
npx wait-on http://localhost:6006
npm run test-storybook -- --url http://localhost:6006
env:
TEST_SESSION_TOKEN: ${{ secrets.TEST_SESSION_TOKEN }}
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: storybook-test-results
path: |
test-results/
storybook-static/The --with-deps chromium flag installs the OS-level dependencies Playwright needs to run Chromium on Ubuntu. Skipping this is one of the most common causes of CI failures.
Filtering Which Stories to Run
You can filter stories by component name or story name using the --testNamePattern flag, which accepts a regex:
# Run only Button stories
npm run test-storybook -- --testNamePattern="Button"
# Run only stories with "Error" in the name
npm run test-storybook -- --testNamePattern="Error"
# Skip stories matching a pattern
npm run test-storybook -- --testPathIgnorePatterns="legacy"This is useful during development when you want fast feedback on specific components without running the entire suite.
Excluding Stories from the Test Runner
Sometimes a story is intentionally broken, or it relies on an environment that the test runner cannot replicate. Use story parameters to skip it:
export const BrokenEnvironmentStory: Story = {
parameters: {
testRunner: {
skip: true,
},
},
};Or skip at the component level in the default export:
const meta: Meta<typeof SomeComponent> = {
component: SomeComponent,
parameters: {
testRunner: {
skip: true,
},
},
};Document why a story is skipped with a comment. Skipped stories tend to stay skipped forever unless there's a clear reason to revisit them.
Debugging Failing Tests
When a story fails in the test runner, you need to figure out if it's a test environment issue or a real bug.
Run with headed mode locally:
npm run test-storybook -- --headedThis opens a visible browser window so you can watch what happens. Add a pause in your play function to freeze execution:
play: async ({ canvasElement }) => {
// ... setup ...
await new Promise(resolve => setTimeout(resolve, 10000)); // pause 10s
// ... rest of play function
},Enable verbose Playwright output:
DEBUG=pw:api npm run test-storybookThis logs every Playwright API call, which is invaluable for understanding what the browser is doing when a getByRole fails to find an element.
Capture screenshots on failure:
In your test-runner config, add screenshot capture in postVisit when a test is failing:
module.exports = {
async postVisit(page, context) {
if (process.env.CAPTURE_SCREENSHOTS) {
await page.screenshot({
path: `screenshots/${context.id}.png`,
fullPage: true,
});
}
},
};Set CAPTURE_SCREENSHOTS=true in CI when running failing tests to get visual evidence of what went wrong.
Check the Storybook logs:
Failed stories often have errors in the browser console. The test runner captures these, but you can also check them explicitly:
async preVisit(page) {
page.on('console', (message) => {
if (message.type() === 'error') {
console.error(`Browser console error: ${message.text()}`);
}
});
},Parallelizing the Test Run
The test runner supports running stories in parallel shards, which is critical for large Storybooks:
# Split into 3 shards, run shard 1
npm run test-storybook -- --shard=1/3
# In CI, run all shards in parallelIn GitHub Actions:
jobs:
test-storybook:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
# ... setup steps ...
- name: Run tests (shard ${{ matrix.shard }}/3)
run: npm run test-storybook -- --shard=${{ matrix.shard }}/3For a Storybook with 300 stories, this reduces CI time from ~15 minutes to ~5 minutes.
Keeping Stories and Tests in Sync
The test runner only knows about stories that exist. If you delete a component but forget to delete its stories, the test runner will try to run those stories and fail. Add a linting step to catch orphaned story files:
# This will error if any story imports a non-existent component
npm run build-storybook -- --quiet 2>&1 | grep -i "error"Better yet, enforce story file naming conventions with ESLint rules from eslint-plugin-storybook. Stories that can't be built can't be tested.
Integrating with Broader QA Workflows
The test runner is excellent at catching regressions in isolated components, but real users experience components assembled into pages with live data flowing through them. For that layer, tools like HelpMeTest provide continuous end-to-end monitoring that runs against your deployed application around the clock — catching issues that only appear when components interact with real APIs, real authentication, and real user data at scale.
The two approaches complement each other: test-runner coverage on component isolation, end-to-end monitoring on full-stack integration.
Summary
The @storybook/test-runner is one of the highest-leverage testing tools available to component library teams. With minimal configuration, you get:
- Smoke tests for every story in your Storybook
- Automated execution of all play functions
- CI integration via GitHub Actions or any other CI provider
- Authentication handling via hooks or decorators
- Parallel sharding for large story counts
The setup investment is small. The payoff — catching broken stories before they reach production — is significant. Start by installing the package and running against your existing stories. You'll likely discover stories that are silently broken the first time you run it.