Storybook Interaction Testing with @storybook/test
Storybook interaction testing lets you simulate user behavior directly inside your stories — clicks, typing, keyboard navigation — and assert the results. Since Storybook 8, all of this runs through the @storybook/test package, which provides a unified API built on top of Testing Library and Vitest.
This guide covers everything you need to write, debug, and run interaction tests in Storybook.
The @storybook/test Package
Before Storybook 8, interaction testing required two packages: @storybook/testing-library for user events and @storybook/jest for assertions. Now there's one:
npm install --save-dev @storybook/testIt exports everything you need:
import { expect, userEvent, within, fn, waitFor } from '@storybook/test';userEvent— simulates realistic browser events (type, click, keyboard, pointer)within— scoped DOM queries, same API as@testing-library/domexpect— assertion library compatible with Vitest/Jest matchersfn— creates spy functions for tracking callswaitFor— polls until an assertion passes
The play Function
A play function is an async function attached to a story that runs after the component renders. This is where your interaction test lives.
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);
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'secret123');
await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));
await expect(canvas.getByText('Welcome back!')).toBeInTheDocument();
},
};The play function receives a context object with:
canvasElement— the root DOM node of the rendered storyargs— the story's current args (including arg controls from the Storybook UI)step— for grouping assertions into named groupscontext— the full story context object
Querying DOM Elements
Use within(canvasElement) to get scoped query functions. This prevents false positives from elements outside your component.
const canvas = within(canvasElement);
// By accessible role (preferred)
canvas.getByRole('button', { name: 'Submit' });
canvas.getByRole('textbox', { name: 'Email' });
canvas.getByRole('checkbox', { name: 'Remember me' });
// By label (good for forms)
canvas.getByLabelText('Password');
// By text content
canvas.getByText('Success!');
// By test id (last resort)
canvas.getByTestId('error-message');Always prefer getBy* over queryBy* in play functions — if the element isn't there, you want a clear failure, not a null.
Simulating User Events
userEvent provides realistic event simulation. Unlike fireEvent, it dispatches the full sequence of browser events that a real user action would trigger.
// Typing
await userEvent.type(canvas.getByLabelText('Search'), 'react testing');
// Clearing and retyping
await userEvent.clear(canvas.getByLabelText('Email'));
await userEvent.type(canvas.getByLabelText('Email'), 'new@example.com');
// Clicking
await userEvent.click(canvas.getByRole('button', { name: 'Delete' }));
// Double click
await userEvent.dblClick(canvas.getByText('Edit'));
// Keyboard shortcuts
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{Escape}');
await userEvent.keyboard('{Control>}z{/Control}'); // Ctrl+Z
// Select from dropdown
await userEvent.selectOptions(
canvas.getByRole('combobox'),
canvas.getByRole('option', { name: 'Option B' })
);
// Tab navigation
await userEvent.tab();
await userEvent.tab({ shift: true }); // Shift+TabAlways await user events — they're asynchronous.
Writing Assertions
Use expect for assertions. It supports all standard matchers plus DOM-specific matchers from @testing-library/jest-dom:
// Presence
await expect(canvas.getByText('Error!')).toBeInTheDocument();
await expect(canvas.queryByText('Error!')).not.toBeInTheDocument();
// Visibility
await expect(canvas.getByRole('dialog')).toBeVisible();
await expect(canvas.getByRole('tooltip')).not.toBeVisible();
// State
await expect(canvas.getByRole('button', { name: 'Submit' })).toBeDisabled();
await expect(canvas.getByRole('checkbox')).toBeChecked();
// Value
await expect(canvas.getByRole('textbox')).toHaveValue('hello');
// Class
await expect(canvas.getByRole('alert')).toHaveClass('error');
// Attribute
await expect(canvas.getByRole('link')).toHaveAttribute('href', '/home');Waiting for Async Updates
When your component makes API calls or has delayed state updates, use waitFor:
import { expect, userEvent, within, waitFor } from '@storybook/test';
export const AsyncSubmit: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Load Data' }));
// Wait for loading state to appear and then resolve
await waitFor(() =>
expect(canvas.queryByText('Loading...')).not.toBeInTheDocument()
);
await expect(canvas.getByText('Data loaded')).toBeInTheDocument();
},
};waitFor retries the assertion every 50ms until it passes or times out (default 1000ms). Increase the timeout for slow operations:
await waitFor(
() => expect(canvas.getByText('Saved')).toBeInTheDocument(),
{ timeout: 3000 }
);Spying on Args and Callbacks
Use fn() to create spy functions and track calls. Combine with args:
const meta: Meta<typeof Button> = {
component: Button,
args: {
onClick: fn(), // Spy on click handler
},
};
export const TrackClick: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
// Assert the callback was called
await expect(args.onClick).toHaveBeenCalledOnce();
await expect(args.onClick).toHaveBeenCalledWith(
expect.objectContaining({ type: 'click' })
);
},
};This pattern is essential for testing components that notify parents via callbacks — modal close handlers, form submit callbacks, selection changes.
Grouping with step
The step function groups interactions into named sections. Steps appear as collapsible items in the Interactions panel, making complex tests much easier to debug.
export const ComplexWorkflow: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Enter personal information', async () => {
await userEvent.type(canvas.getByLabelText('First name'), 'Jane');
await userEvent.type(canvas.getByLabelText('Last name'), 'Smith');
});
await step('Select preferences', async () => {
await userEvent.click(canvas.getByLabelText('Newsletter'));
await userEvent.selectOptions(
canvas.getByLabelText('Frequency'),
'weekly'
);
});
await step('Submit and verify', async () => {
await userEvent.click(canvas.getByRole('button', { name: 'Save' }));
await expect(canvas.getByText('Preferences saved')).toBeVisible();
});
},
};Debugging in the Interactions Panel
When a play function fails, the Interactions panel shows:
- The step that failed (highlighted in red)
- The exact assertion that didn't pass
- A replay button to re-run from any step
- Time-travel through each interaction
Click the replay button on a step to re-run from that point. Click the step marker to jump to that point in the interaction timeline. This makes debugging interaction tests significantly faster than reading stack traces.
Reusing play Functions
You can compose play functions across stories:
export const Filled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password');
},
};
export const SubmittedWithValidData: Story = {
play: async (context) => {
// Reuse Filled's interactions
await Filled.play!(context);
// Then submit
const canvas = within(context.canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));
await expect(canvas.getByText('Welcome!')).toBeInTheDocument();
},
};This keeps tests DRY without sacrificing readability.
Running Interaction Tests in CI
Use the Storybook test runner to run all interaction tests in CI:
npm install --save-dev @storybook/test-runner# .github/workflows/test.yml
- name: Build Storybook
run: npm run build-storybook
- name: Run 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"Failed play functions show up as test failures with the full assertion error and step path.
Common Patterns
Testing error states:
export const ValidationError: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Submit without filling required fields
await userEvent.click(canvas.getByRole('button', { name: 'Submit' }));
await expect(canvas.getByRole('alert')).toHaveTextContent('Email is required');
},
};Testing keyboard accessibility:
export const KeyboardNavigation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const firstItem = canvas.getByRole('menuitem', { name: 'Home' });
firstItem.focus();
await userEvent.keyboard('{ArrowDown}');
await expect(canvas.getByRole('menuitem', { name: 'About' })).toHaveFocus();
},
};Testing modal open/close:
export const OpenAndClose: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' }));
await expect(canvas.getByRole('dialog')).toBeVisible();
await userEvent.keyboard('{Escape}');
await expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
},
};Storybook interaction testing covers the component behavior layer that unit tests miss and E2E tests make expensive. Once a play function exists, it's documentation, a test, and a development tool simultaneously.