Make Storybook Stories Prove Themselves: Play Functions and the Test-Runner in CI
Storybook play functions turn stories into executable specs, and the test-runner enforces them in CI. A worked dropdown example shows the pattern, plus the version caveats and trade-offs.
16 Sept 2025, 05:59 UTC

Here's a regression that slips through code review constantly: a dropdown whose popover should close when you click outside it. Someone refactors the component, the outside-click handler gets lost, and nobody notices — because every Storybook story shows the dropdown already open, looking perfect. The visual catalog says everything is fine while the behavior is broken.
The thesis of this post: a story should be an executable specification, not just a screenshot with props. Storybook's play functions let a story simulate clicks and typing and assert outcomes, and the @storybook/test-runner runs every story in CI so those assertions are enforced. One artifact ends up doing three jobs: documentation, manual exploration, and automated regression coverage.
What a play function actually is
Since around Storybook 6.4, any Component Story Format story can define an async play function that runs after the story renders in the browser. Inside it you use Testing Library–style queries (within, userEvent) to interact with the rendered component and expect to assert results. While you develop, the Interactions addon panel replays each step inline, so you get a pass/fail signal without leaving the story.
One version caveat before the code: import paths changed across major versions. In Storybook 7 and 8, the utilities come from the consolidated @storybook/test package; in 6.x they were split across @storybook/testing-library and @storybook/jest. The snippets below target the 7/8 layout — check the docs for your installed major before copying, and be aware that newer majors are steering toward Vitest-based portable stories, so confirm the currently recommended testing path.
Worked example: the dropdown contract
This story captures the exact contract that kept regressing — open on trigger click, close on outside click:
// Dropdown.stories.tsx — Storybook 7/8, CSF3
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Dropdown } from './Dropdown';
const meta: Meta<typeof Dropdown> = {
component: Dropdown,
title: 'Forms/Dropdown',
};
export default meta;
type Story = StoryObj<typeof Dropdown>;
export const ClosesOnOutsideClick: Story = {
args: { label: 'Assignee', items: ['Ada', 'Grace', 'Edsger'] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Popover starts closed
await expect(body.queryByRole('listbox')).not.toBeInTheDocument();
// Click the trigger: popover opens
await userEvent.click(canvas.getByRole('button', { name: /assignee/i }));
await expect(await body.findByRole('listbox')).toBeVisible();
// Click outside: popover closes
await userEvent.click(canvasElement.ownerDocument.body);
await expect(body.queryByRole('listbox')).not.toBeInTheDocument();
},
};A few deliberate choices here. The popover renders in a portal, so it's queried from document.body, not the story's canvas element — a common gotcha. Queries are role- and name-based rather than CSS selectors, which makes the test resilient to markup shuffles and doubles as a light accessibility check. And findByRole waits for the element to appear instead of assuming synchronous state updates; fixed sleep calls are the main source of flaky play functions.
Now, when someone deletes the outside-click handler, the story itself fails — in the Interactions panel during development, and in CI, as shown next.
Enforcing it in CI with the test-runner
@storybook/test-runner is a standalone, Playwright-based tool. It visits every story in a served Storybook build and fails the run if a story throws a render error or a play function assertion fails. It enumerates stories from the build's generated index rather than compiling components itself, so you're testing the same bundled output developers browse.
The pipeline has four steps, and skipping the middle two is the most common cause of CI failures with connection errors:
- Build the static bundle:
npm run build-storybook(outputs tostorybook-staticby default). - Serve it locally, e.g.
npx http-server storybook-static --port 6006(any static file server works). - Wait for the server to respond —
npx wait-on http://127.0.0.1:6006is the usual choice. - Run the tests:
npx test-storybook --url http://127.0.0.1:6006.
Run these in your CI job's shell with whatever user the pipeline uses; no special permissions are needed beyond installing dependencies. A passing run exits 0 and lists each story's result; to validate the setup, deliberately break one assertion (e.g. expect the listbox to stay open) and confirm the job fails and names the offending story. Then revert. That negative check matters — a suite that can't fail isn't enforcing anything.
Where this fits — and where it doesn't
Play-function tests run against a real browser render of the component, but not the surrounding application: no routing, no data fetching, no network behavior. They complement unit tests and full end-to-end tests rather than replacing either. Think of them as the middle layer that locks in component-level interaction contracts.
There are two honest costs. First, speed: each story is effectively a page visit, so large suites get slow — shard in CI or reserve play functions for stories with real interaction logic rather than every visual variant. Second, version drift: Storybook's testing story has changed across majors and is still evolving, so treat the import paths and commands above as a 7/8-era pattern and verify against the docs and migration guide for your version.
Try it this week
Pick one component with an interaction contract that's regressed before — a modal's Escape key, a form's disabled submit, that dropdown. Write a play function for it, watch it pass in the Interactions panel, then wire the build → serve → wait → test-storybook sequence into CI and break it once on purpose. From then on, the story review catches the regression instead of QA.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.