Writing Play Functions for Storybook Interaction Tests
A play function is the unit of behaviour in a Storybook interaction test: an async callback that runs after a story mounts, drives the component with simulated user input, and asserts on the result. Written well, one story becomes both living documentation and an executable spec; written carelessly, it becomes a flaky, implementation-coupled liability. This guide is for frontend engineers and QA specialists on Storybook 8 with React 18/19 who want to script realistic component journeys using userEvent and expect from the unified @storybook/test package. It assumes you already have stories rendering and the interactions addon enabled; the focus here is the craft of the play function itself — querying, interacting, asserting, injecting spies via args, and mocking dependencies. For the surrounding setup, see the overview of Storybook interaction tests.
Root Cause Analysis
Most failing or flaky play functions trace back to one of three mistakes, and each maps to a misunderstanding of how Storybook executes a story.
The first is querying the wrong root. The play function receives a canvasElement, the DOM node for that story’s render. If you query the global screen instead, you can resolve elements from the Storybook toolbar, sidebar, or a neighbouring story, producing matches that are correct by accident and wrong under change.
The second is racing the render. userEvent interactions and state updates are asynchronous. A synchronous getByText evaluated immediately after a click will throw if the component re-renders on the next tick. The symptom is a test that passes locally on a fast machine and fails intermittently in CI — the classic profile of a flaky test.
The third is asserting implementation details rather than user-observable outcomes. Reaching into component internals or class names couples the test to markup that will churn. The same query discipline that underpins Testing Library best practices applies verbatim here: query by role, label, and text, and assert on what a user can perceive.
It helps to understand what a play function actually is in execution terms. Storybook renders the story, then calls play with a context object containing canvasElement, the resolved args, and the story’s other configuration. The function runs in the same browser context as the rendered component, so there is no serialization boundary, no separate process, and no mocking layer between your interactions and the real DOM events the component handles. That fidelity is the whole point — but it also means the play function inherits all of the browser’s asynchrony. Every userEvent call returns a promise that resolves after the event has been dispatched and the microtask queue has drained, which is why you await each one. Skipping an await lets the next line run before the component has reacted, and that single omission is behind a surprising share of intermittent failures.
Reproducible Setup
Confirm you are on Storybook 8 with the unified test package. Everything you need for interactions comes from a single import path.
npm install @storybook/test --save-dev
A minimal CSF3 story uses an object default export for meta and named exports for stories. The component under test here is a small newsletter form.
// NewsletterForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { NewsletterForm } from './NewsletterForm';
const meta: Meta<typeof NewsletterForm> = {
title: 'Marketing/NewsletterForm',
component: NewsletterForm,
args: { onSubscribe: fn() }, // a spy, injected as a prop
};
export default meta;
type Story = StoryObj<typeof NewsletterForm>;
export const Default: Story = {};
The fn() helper creates a spy you can later assert against — preferable to declaring a bare vi.fn() because it is wired into the Interactions panel and reset per story render.
Implementation
Build the play function up one capability at a time.
1. Scope every query to the canvas. Wrap canvasElement in within so queries can only see the story.
import { within, userEvent, expect } from '@storybook/test';
export const SubscribesSuccessfully: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const email = canvas.getByLabelText('Email address');
await userEvent.type(email, 'grace@example.com');
await userEvent.click(canvas.getByRole('button', { name: /subscribe/i }));
await expect(args.onSubscribe).toHaveBeenCalledWith('grace@example.com');
},
};
2. Await asynchronous appearances. When a node renders after an interaction, use findBy*, which retries until the element exists or the timeout fires.
export const ShowsConfirmation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email address'), 'lin@example.com');
await userEvent.click(canvas.getByRole('button', { name: /subscribe/i }));
const banner = await canvas.findByRole('status');
await expect(banner).toHaveTextContent('Check your inbox');
},
};
3. Override args per story. Stories inherit meta.args and can override them, letting you script distinct scenarios from the same component without new files.
export const PrefilledEmail: Story = {
args: { defaultEmail: 'pre@example.com' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText('Email address')).toHaveValue('pre@example.com');
},
};
4. Mock dependencies before the play runs. When the component calls a module, mock it at the story boundary with a loader or a decorator so the play function exercises deterministic data — the same isolation principle behind mocking network in Playwright component tests.
export const HandlesServerError: Story = {
args: {
// inject a spy that rejects, simulating a failed request
onSubscribe: fn(async () => {
throw new Error('500');
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email address'), 'err@example.com');
await userEvent.click(canvas.getByRole('button', { name: /subscribe/i }));
await expect(await canvas.findByRole('alert')).toHaveTextContent('Try again');
},
};
5. Simulate keyboard navigation. userEvent models real keyboard behaviour, which is essential for testing focus order and accessibility.
export const KeyboardSubmits: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email address'), 'kb@example.com{enter}');
await expect(args.onSubscribe).toHaveBeenCalledOnce();
},
};
Verification
Run the story interactively first. In the Storybook UI, open the story and watch the Interactions panel: each userEvent and expect appears as a discrete, replayable step, with a red marker on the exact assertion that failed. This is the fastest way to debug a play function because you can pause execution mid-flow and inspect the live DOM.
From the terminal, the test-runner reports each story’s result:
PASS Marketing/NewsletterForm SubscribesSuccessfully
PASS Marketing/NewsletterForm ShowsConfirmation
PASS Marketing/NewsletterForm HandlesServerError
Tests: 3 passed, 3 total
A spy assertion that resolves confirms the contract between the component and its callbacks; a findByRole('alert') that resolves confirms the error path renders. When both pass, the story documents and verifies the same behaviour at once.
Treat the Interactions panel as your primary debugger before reaching for any other tool. Because every step is recorded with its target element and matcher, a red marker on a failed expect shows you the exact assertion and the DOM at that instant. Step backward to the interaction immediately before the failure, inspect the live canvas, and you usually see the problem without changing a line of code — a value that did not commit, a button still disabled, a label that never rendered. Reserve canvas.debug() for cases where you need the full serialized tree in the console, for example when an element is present but a query cannot find it because the accessible name differs from what you expected.
Troubleshooting
getBy* throws “Unable to find an element”. The element renders asynchronously. Replace the synchronous query with its findBy* equivalent, which polls. If the element genuinely never appears, log canvas.debug() inside the play function to print the current DOM.
The spy reports unexpected call counts. You reused a fn() across stories without resetting it, so calls accumulated. Declare the spy in meta.args (or directly in the story’s args) so each render gets a fresh instance instead of sharing one module-level mock.
Queries match the Storybook chrome. You used the global screen rather than scoping to the canvas. Always start with const canvas = within(canvasElement) and query off canvas, never screen.
FAQ
Do I import userEvent and expect from Testing Library or from Storybook?
Import them from @storybook/test in Storybook 8. That package re-exports a browser-compatible userEvent, a Vitest-style expect with DOM matchers, within, and fn, and it wires each call into the Interactions panel so you get step-by-step replay. Importing the raw Testing Library packages instead loses that instrumentation and can pull in jsdom-specific behaviour that does not match the real browser the story runs in.
How do I assert that a callback prop was called?
Inject the callback through args using fn(), then assert against it in the play function with matchers like toHaveBeenCalledWith or toHaveBeenCalledOnce. Because the spy lives in args, it is reset on each story render and shows up in the Interactions timeline, so you can see exactly when and with what arguments it fired.
Why does my play function pass locally but fail in CI?
Almost always a timing race: a synchronous query runs before an async state update completes. Local machines are fast enough to hide it, but a loaded CI runner is not. Switch synchronous getBy* calls that follow an interaction to findBy*, which retries until the element resolves. If it persists, treat it as a defect using the techniques in flaky test mitigation rather than adding blind retries.
Can a play function call another story’s play function?
Yes. Import the named story and invoke its play inside your own to compose flows — for example, run a Login story’s play to reach an authenticated state before testing a dashboard interaction. Pass through the same context object so the composed steps run against the same canvas.
Related
- Back to Storybook interaction tests
- Running Storybook tests in CI with the test-runner — execute these play functions headlessly
- Testing Library best practices — the query discipline behind reliable play functions
- Flaky test mitigation — diagnosing timing races in interaction tests