Interaction Testing in Storybook
This page is part of the Storybook Isolation Workflows guide. Interaction testing sits between story authoring and visual regression capture: you drive a component into a specific state using simulated user events, then assert on the resulting DOM before a snapshot baseline is recorded.
The play function is the execution engine. It runs inside the story iframe after the component mounts, giving you direct access to a real browser DOM — no jsdom approximations. Every assertion that passes here feeds into your visual regression snapshot strategies baseline, ensuring the captured image reflects the actual interactive state rather than the initial paint.
How the play function fits into the test pipeline
The diagram below shows how a story’s play function sits between component render and snapshot capture, and how the same story file drives both the Storybook UI step-debugger and headless CI via test-storybook.
Prerequisites
Step-by-step implementation
Step 1 — Install the interactions addon and test utilities
Intent: Add the two packages that unlock play functions and the step-debugger panel. Both are dev dependencies only; they have no effect on production bundles.
npm install -D @storybook/addon-interactions @storybook/test
@storybook/test re-exports @testing-library/user-event, @testing-library/dom, and Vitest’s expect API under a single namespace, so your story files have one import for all assertion and simulation helpers.
Verify: Run npm ls @storybook/test — the installed version should match your Storybook major version (e.g. 8.x).
Step 2 — Register the addon in main.ts
Intent: Tell Storybook to load the interactions panel and wire up the step-replay UI. Without this entry the panel will not appear, even if play is defined on stories.
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions', // enables the Interactions panel
],
framework: {
name: '@storybook/react-vite',
options: {},
},
core: {
disableTelemetry: true,
},
};
export default config;
Verify: Start Storybook with npx storybook dev. Open any story; a new Interactions tab should appear in the addons panel at the bottom of the UI.
Step 3 — Write your first play function
Intent: Simulate a real user clicking a button, then assert on the DOM changes that result. The within helper scopes all queries to the story’s canvas element, preventing false matches against Storybook’s own UI chrome.
// src/components/Button/Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect } from '@storybook/test';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
title: 'Design System/Button',
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Button>;
export const SubmitFlow: Story = {
args: { label: 'Save changes', variant: 'primary' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: /save changes/i });
// Assert initial state before any interaction
expect(button).toBeEnabled();
expect(button).not.toHaveAttribute('aria-disabled', 'true');
// Simulate a real pointer click (fires mousedown, mouseup, click in sequence)
await userEvent.click(button);
// Assert post-interaction state
expect(button).toHaveAttribute('aria-busy', 'true');
expect(canvas.getByRole('status')).toHaveTextContent('Saving…');
},
};
Verify: Open the SubmitFlow story in Storybook. The Interactions panel should show each step with a green checkmark. Hovering a step highlights the relevant DOM node in the canvas.
Step 4 — Add waitFor guards for async state transitions
Intent: Components that defer state updates via useEffect, data fetching, or animation callbacks require explicit wait boundaries. Without waitFor, the assertion fires before React has flushed the update, causing intermittent failures in headless CI.
// src/components/SearchInput/SearchInput.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect, waitFor } from '@storybook/test';
import { SearchInput } from './SearchInput';
const meta: Meta<typeof SearchInput> = {
component: SearchInput,
title: 'Design System/SearchInput',
};
export default meta;
type Story = StoryObj<typeof SearchInput>;
export const TypeAndSuggest: Story = {
args: { placeholder: 'Search components…' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('combobox', { name: /search components/i });
await userEvent.type(input, 'But');
// waitFor retries until the listbox appears (default timeout: 1000 ms)
await waitFor(() =>
expect(canvas.getByRole('listbox')).toBeInTheDocument()
);
const firstOption = canvas.getAllByRole('option')[0];
expect(firstOption).toHaveTextContent(/button/i);
await userEvent.click(firstOption);
await waitFor(() =>
expect(input).toHaveValue('Button')
);
},
};
The waitFor boundary is critical: it retries the wrapped assertion on every animation frame until it passes or the timeout expires. Never assert directly on async DOM changes without it.
Verify: Run npx test-storybook --url http://localhost:6006 --stories SearchInput/TypeAndSuggest. The test should pass consistently across three consecutive runs.
Step 5 — Assert on accessible roles, not CSS classes
Intent: Queries against data-testid or ARIA roles are stable across styling refactors. Queries against CSS class names break whenever a design token is renamed — a common source of false failures in design system repos.
// Prefer this:
const dialog = canvas.getByRole('dialog', { name: /confirm deletion/i });
const closeBtn = within(dialog).getByRole('button', { name: /cancel/i });
// Avoid this (brittle):
// const closeBtn = canvasElement.querySelector('.modal__close-btn');
The within(dialog) scope is especially useful for modals and drawers: it prevents the query from escaping the dialog boundary and matching a button elsewhere in the DOM.
Verify: Rename the CSS class used for the close button. The play function test must still pass; only a visual regression diff should be produced.
Step 6 — Configure test-runner.ts for CI execution
Intent: The Storybook test runner uses Playwright to load each story in headless Chromium and execute its play function. The config below adds an axe-core accessibility sweep after each story’s interactions resolve, merging functional and accessibility checks into one CI job.
// .storybook/test-runner.ts
import { injectAxe, checkA11y } from 'axe-playwright';
import { getStoryContext } from '@storybook/test-runner';
import type { TestRunnerConfig } from '@storybook/test-runner';
const config: TestRunnerConfig = {
async preVisit(page) {
// Inject axe-core before the story mounts
await injectAxe(page);
},
async postVisit(page, context) {
const storyContext = await getStoryContext(page, context);
// Allow individual stories to opt out with parameters: { a11y: { disable: true } }
if (storyContext.parameters?.a11y?.disable) return;
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: { html: true },
});
},
};
export default config;
Verify: Run npx test-storybook --url http://localhost:6006. Any story with a missing aria-label or insufficient colour contrast should produce a detailed axe violation report.
Step 7 — Wire CI gating in GitHub Actions
Intent: Block pull requests automatically when any play function assertion or accessibility check fails. Cache the Storybook static build to keep median pipeline time under three minutes.
# .github/workflows/interaction-tests.yml
name: Interaction Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
interaction-tests:
name: Run interaction tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci --prefer-offline
- name: Restore Storybook build cache
uses: actions/cache@v4
with:
path: storybook-static
key: sb-${{ runner.os }}-${{ hashFiles('package-lock.json', '.storybook/**') }}
restore-keys: sb-${{ runner.os }}-
- name: Build Storybook
run: npx storybook build --quiet
- name: Serve and run interaction tests
run: |
npx http-server storybook-static --port 6006 --silent &
npx wait-on http://localhost:6006
npx test-storybook --url http://localhost:6006 --coverage
env:
CI: true
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: interaction-coverage
path: coverage/storybook/
retention-days: 14
Verify: Open a pull request that introduces a deliberate failing assertion in a play function. The interaction-tests job must fail and block the merge.
Configuration reference
| Option / API | Type | Default | Effect |
|---|---|---|---|
play |
async ({ canvasElement, args, step }) => void |
— | Runs after story mounts; receives the canvas root element and resolved story args. |
within(element) |
BoundingQueries |
— | Scopes all Testing Library queries to a specific DOM subtree. Use to prevent cross-component false matches. |
userEvent.click(element) |
Promise<void> |
— | Fires a full synthetic pointer sequence: pointerover, pointerenter, mouseover, mouseenter, pointermove, mousemove, pointerdown, mousedown, pointerup, mouseup, click. |
userEvent.type(element, text) |
Promise<void> |
— | Types each character with keydown, keypress, input, keyup events — matches real browser behaviour. |
userEvent.tab() |
Promise<void> |
— | Moves focus to the next focusable element in tab order. |
waitFor(fn, options?) |
Promise<void> |
timeout: 1000 ms | Retries fn until it stops throwing, then resolves. Increase timeout for slow animations. |
step(title, fn) |
Promise<void> |
— | Wraps a set of interactions in a named step visible in the Interactions panel. Aids debugging by collapsing related actions. |
parameters.interactions.disable |
boolean |
false |
Disables the interactions addon for a specific story. Does not skip play execution in test-storybook. |
--coverage (CLI flag) |
boolean | false | Generates an Istanbul coverage report at coverage/storybook/. |
--shard (CLI flag) |
index/total e.g. 1/4 |
— | Splits the story set into parallel shards for faster CI execution. |
Common pitfalls
1. Missing waitFor after async state updates.
A userEvent.click that triggers a fetch call or a debounced setState will not have resolved by the time the next synchronous expect line runs in headless Chromium. Wrap every post-async assertion in waitFor(() => expect(...)) — not just the ones that “sometimes fail”.
2. Scoping queries to document instead of canvasElement.
Using screen.getByRole(...) (global scope) instead of within(canvasElement).getByRole(...) causes queries to match Storybook’s own toolbar buttons and navigation links. Always construct your canvas from within(canvasElement).
3. Relying on setTimeout to synchronise state.
Adding await new Promise((r) => setTimeout(r, 500)) as a workaround for flaky timing is a test smell. The hidden bug will resurface on slower CI runners. Replace every hard delay with a waitFor that asserts the expected DOM condition directly.
4. Forgetting to reset side effects between play steps.
If a story mutates global state — a Zustand store, a document event listener, an MSW handler override — and that story runs before a sibling story in test-storybook, state leaks across tests. Use a beforeEach hook in test-runner.ts or a loaders function on the story to reset to a known baseline before each run. The mock boundaries page covers MSW handler reset patterns in detail.
5. Authoring interaction tests in stories tagged autodocs.
The autodocs tag causes Storybook to render a docs page that also embeds the story. If a play function fires on the autodocs page it runs in a different DOM context and will likely fail. Remove the autodocs tag from stories with complex play functions, or scope the autodocs render to a separate DocsOnly export.
Integration points
Interaction testing connects to several adjacent concerns:
- Upstream — component variants: Each named variant story can carry a
playfunction to drive the component into its interactive state before a snapshot baseline is captured. This ensures Chromatic records the post-interaction visual, not just the initial paint. - Upstream — argtable mapping: Correctly typed
argTypesensure thatargspassed intoplayvia the second argument are always the resolved, type-safe prop set — no runtime surprises from undefined optional props. - Downstream — visual regression snapshot strategies: When Chromatic captures a story that has a
playfunction, it waits for the function to complete before taking the snapshot. The interaction pipeline here therefore directly determines baseline quality. - Downstream — writing interaction tests with Storybook play function: That long-form page covers advanced patterns including multi-step flows, composable interaction suites, and keyboard-navigation sequences.
- Adjacent — addon ecosystems: The MSW Storybook addon integrates with
playfunctions to let you assert on network requests as part of the interaction flow — useful for testing optimistic UI patterns.
FAQ
Do play functions replace React Testing Library tests?
They are complementary, not replacements. Play functions validate component behaviour in a real browser at the story level, while RTL unit tests run in jsdom and are faster for pure logic paths. Use play functions when the visual state after interaction matters — confirming focus rings, checking ARIA attribute changes, verifying animation end states — and RTL for synchronous logic that does not depend on the browser rendering engine.
Why does my play function pass locally but fail in CI?
The most common cause is a missing waitFor guard around assertions that depend on asynchronous state updates. In headless Chromium the event loop ticks at a different cadence than local Chrome dev builds. Wrap every assertion that follows a userEvent call in waitFor(() => expect(...)) and the test becomes timing-safe across environments.
Can I share play function logic across multiple stories?
Yes. Extract interaction steps into a named async function — for example an openDropdown(canvasElement) helper in src/test-utils/interactions.ts that uses within to scope the canvas and userEvent.click to open the menu — then call it from any story’s play. This keeps the test surface DRY and makes step reuse explicit.
How do I test keyboard navigation inside a play function?
Use userEvent.tab() to move focus through the tab sequence and userEvent.keyboard('{Enter}') or userEvent.keyboard('{Space}') to activate the focused element. Combine with waitFor(() => expect(element).toHaveFocus()) to assert that focus has landed on the correct target before the next step fires.
Related
- Storybook Isolation Workflows — parent section covering the full Storybook-based testing discipline
- Writing Interaction Tests with Storybook Play Function — deep-dive into multi-step flows, composable suites, and keyboard navigation sequences
- Component Variants — author named variant stories and drive them into interactive states before snapshot capture
- Argtable Mapping — model complex prop shapes as Storybook argTypes for type-safe play function args
- Mock Boundaries — reset MSW handlers and async side effects between interaction test runs
- Visual Regression Snapshot Strategies — capture Chromatic baselines after play functions resolve to the post-interaction state