Storybook Interaction Testing with Play Functions and @storybook/test
Storybook stories document your components. Play functions make them executable tests. With @storybook/test and play functions, you can write interactions directly in your stories, run them in the Storybook UI with a visual step-by-step debugger, and execute them headlessly in CI.
The result is documentation that is also tests — a story that demonstrates a form submission also verifies the form actually submits correctly.
What Play Functions Are
A play function is an async function attached to a Storybook story that runs after the story renders. It uses @testing-library/user-event and @testing-library/jest-dom assertions through the @storybook/test package.
// src/components/LoginForm/LoginForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import LoginForm from './LoginForm';
const meta: Meta<typeof LoginForm> = {
component: LoginForm,
title: 'Forms/LoginForm',
};
export default meta;
type Story = StoryObj<typeof LoginForm>;
export const SuccessfulLogin: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Fill in the form
await userEvent.type(
canvas.getByLabelText('Email'),
'user@example.com',
{ delay: 50 }
);
await userEvent.type(
canvas.getByLabelText('Password'),
'correct-password',
{ delay: 50 }
);
// Submit
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
// Assert the onLogin callback was called
await expect(args.onLogin).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'correct-password',
});
},
args: {
onLogin: fn(),
},
};When you view this story in Storybook, the play function runs automatically and you see each step highlighted with a pass/fail indicator in the "Interactions" panel.
Installing @storybook/test
npm install --save-dev @storybook/testIf you're on Storybook 7, you may have @storybook/jest and @storybook/testing-library instead. Storybook 8 consolidates these into @storybook/test. Migrate by replacing:
// Old (Storybook 7)
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
// New (Storybook 8)
import { within, userEvent, expect, fn } from '@storybook/test';userEvent API
@storybook/test re-exports userEvent from @testing-library/user-event. The most commonly used methods:
// Typing text
await userEvent.type(element, 'text to type');
await userEvent.type(element, 'slow typing', { delay: 50 }); // ms between keystrokes
// Clicking
await userEvent.click(element);
await userEvent.dblClick(element);
await userEvent.click(element, { ctrlKey: true }); // modifier keys
// Keyboard
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{Tab}');
await userEvent.keyboard('{Escape}');
// Selecting
await userEvent.selectOptions(selectElement, 'Option Value');
await userEvent.deselectOptions(selectElement, 'Option Value');
// Clearing
await userEvent.clear(inputElement);
// Hover
await userEvent.hover(element);
await userEvent.unhover(element);
// Upload
await userEvent.upload(fileInput, file);The fn() Spy
The fn() function from @storybook/test creates a Vitest-compatible spy (actually wraps @vitest/spy). Use it to track callback calls:
import { fn } from '@storybook/test';
export const WithCallbackTracking: Story = {
args: {
onSubmit: fn(),
onCancel: fn(),
onChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByRole('textbox'), 'hello');
await expect(args.onChange).toHaveBeenCalledTimes(5); // one per character
await userEvent.click(canvas.getByRole('button', { name: /cancel/i }));
await expect(args.onCancel).toHaveBeenCalledOnce();
await expect(args.onSubmit).not.toHaveBeenCalled();
},
};Multi-Step Stories
Break complex interactions into steps for better debuggability:
import { within, userEvent, expect, fn, step } from '@storybook/test';
export const CheckoutFlow: Story = {
args: {
onOrderPlaced: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await step('Fill shipping address', async () => {
await userEvent.type(canvas.getByLabelText('Street'), '123 Main St');
await userEvent.type(canvas.getByLabelText('City'), 'Springfield');
await userEvent.selectOptions(canvas.getByLabelText('State'), 'IL');
await userEvent.type(canvas.getByLabelText('ZIP'), '62701');
});
await step('Fill payment details', async () => {
await userEvent.type(canvas.getByLabelText('Card number'), '4242 4242 4242 4242');
await userEvent.type(canvas.getByLabelText('Expiry'), '12/26');
await userEvent.type(canvas.getByLabelText('CVC'), '123');
});
await step('Submit order', async () => {
await userEvent.click(canvas.getByRole('button', { name: /place order/i }));
await expect(args.onOrderPlaced).toHaveBeenCalledOnce();
});
await step('Show confirmation', async () => {
await expect(canvas.getByText(/order confirmed/i)).toBeInTheDocument();
await expect(canvas.getByText(/confirmation number/i)).toBeInTheDocument();
});
},
};Steps appear as collapsible sections in the Interactions panel. When a step fails, you see exactly which step and which assertion, not just the line number.
Running Storybook Tests in CI
Install the test runner:
npm install --save-dev @storybook/test-runnerAdd to package.json:
{
"scripts": {
"test-storybook": "storybook test",
"test-storybook:ci": "storybook test --ci"
}
}The test runner needs a running Storybook instance:
# Start Storybook, then in another terminal:
npm run test-storybookOr use concurrently / wait-on for CI:
# .github/workflows/storybook-tests.yml
name: Storybook Interaction Tests
on:
push:
branches: [main, 'feature/**']
pull_request:
jobs:
storybook-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build Storybook
run: npm run build-storybook -- --quiet
- name: Run Storybook tests
run: |
npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && npm run test-storybook:ci"The test runner uses Playwright under the hood. It visits each story, runs the play function, and reports pass/fail. Failures include a screenshot of the failing state.
Configuring the Test Runner
// .storybook/test-runner.js
const { getStoryContext } = require('@storybook/test-runner');
module.exports = {
async preVisit(page, context) {
// Run before each story
await page.setViewportSize({ width: 1280, height: 800 });
},
async postVisit(page, context) {
// Run after each story — useful for accessibility checks
const storyContext = await getStoryContext(page, context);
if (storyContext.parameters?.a11y?.disable) return;
// Run axe accessibility check on every story
const results = await page.evaluate(async () => {
const axe = window.axe;
return await axe.run(document.getElementById('storybook-root'));
});
if (results.violations.length > 0) {
const messages = results.violations
.map(v => `${v.id}: ${v.description}`)
.join('\n');
throw new Error(`Accessibility violations:\n${messages}`);
}
},
};Integrating with Chromatic
Chromatic runs your stories and captures visual snapshots. Combined with play functions, it captures the rendered state after interactions complete.
npm install --save-dev chromatic
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKENIf a story has a play function, Chromatic runs the play function before taking the snapshot. So your SuccessfulLogin story snapshot shows the form after it's been filled in and submitted — not the initial empty state.
# In your CI workflow:
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
onlyChanged: true # TurboSnap — only snapshot changed storiesComparing with Cypress Component Tests
Both Storybook play functions and Cypress component tests run component-level interactions in a real browser. The difference is in philosophy:
Storybook play functions live with your stories. They test the story, not the component in isolation. They're ideal when you want documentation and tests to be the same file.
Cypress component tests live in a separate test file. They give you full Cypress power: time-travel debugging, the Cypress command chain, .should() assertions with automatic retries.
| Factor | Storybook play | Cypress component |
|---|---|---|
| Lives with | Stories | Separate test files |
| Debugger | Interactions panel (step-by-step) | Cypress time-travel |
| Network interception | Manual (msw integration) | Built-in cy.intercept() |
| CI runner | Playwright (via test-runner) | Cypress |
| Visual regression | Chromatic integration | Separate setup needed |
| Assertion retries | Limited | Built-in (Cypress retries) |
For teams already using Storybook, play functions are the zero-friction path to interaction testing — no new tool, no new workflow. If your team is Cypress-first and already has E2E tests, component tests in Cypress may feel more natural.
Many teams use both: Storybook play functions for documenting happy-path interactions, Cypress component tests for edge cases and error states.