Catching UI Regressions with Storybook's Interaction Tests
Storybook's play functions let you attach behavior assertions directly to stories, turning your component docs into a regression suite. Here's the setup, a worked example, and the limits.
19 Dec 2025, 17:44 UTC

Your button works. You clicked it in the browser, the label changed, ship it. Two weeks later a refactor of a shared hook silently breaks the click handler, and nobody notices until a user does. The gap isn't testing in general — it's that your component library's living documentation (Storybook) and your behavior tests live in two different worlds, so the tests drift out of date while the stories stay current.
Storybook's interaction testing closes that gap by letting you attach assertions directly to a story. The story stays the documentation; the test rides along with it. This post shows how the feature works, walks through a runnable example, and covers where it falls short.
What a play function actually is
Every Storybook story can define a play function — a small script that runs after the story renders. Inside it you get utilities from @storybook/test: userEvent (a wrapper around Testing Library's user-event for simulating clicks and typing), within (scoped queries against the story's canvas), and a Jest-compatible expect.
Two execution environments matter:
- In the browser: with
@storybook/addon-interactionsinstalled, the Interactions panel shows each step of the play function as it runs, so you can watch the click happen and see which assertion failed. - In CI:
@storybook/test-runnervisits every story in a real browser (via Playwright), executes each play function, and reports pass/fail in the terminal. Stories without play functions are smoke-tested for render errors.
The feature has been stable since Storybook 6.4, so this works on any reasonably current version — the examples below assume Storybook 7.x/8.x with the @storybook/test package.
Setup
Install the two pieces as dev dependencies:
npm install --save-dev @storybook/addon-interactions @storybook/test-runnerRegister the addon in .storybook/main.js:
export default {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
};The test runner needs a running Storybook instance to test against. Add a script to package.json:
"test:storybook": "test-storybook"Then, in one terminal, start Storybook with npm run storybook; in another, run npm run test:storybook. (For CI you can use test-storybook --url against a built static Storybook instead.) Run these from your project root; no special permissions needed beyond a normal Node environment, though the first run downloads a Playwright browser if you don't already have one.
A worked example: toggle button
Here's a small button that toggles its label when clicked, and the story that proves it:
// ToggleButton.stories.jsx
import { expect, userEvent, within } from '@storybook/test';
import { ToggleButton } from './ToggleButton';
export default {
title: 'ToggleButton',
component: ToggleButton,
};
export const TogglesLabel = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await expect(button).toHaveTextContent('Off');
await userEvent.click(button);
await expect(button).toHaveTextContent('On');
},
};Run npm run test:storybook and you should see the story listed as passing. The useful check: deliberately break the assertion — change 'On' to 'Enabled' — and rerun. The runner should report a failure naming the story and the mismatched text. That round trip confirms the feedback loop works before you trust it with real components.
Note the await on every step. Play functions are async, and forgetting to await a userEvent or expect is the most common cause of tests that pass locally but flake in CI.
Where this fits — and where it doesn't
Interaction tests are excellent for behavior: clicks, form input, conditional rendering, keyboard flows. They are not a substitute for two other things:
- Visual regression. The runner executes in a browser but only checks your assertions, not pixels. A broken flexbox layout passes happily. Keep a visual tool (Chromatic, Percy, or screenshot tests) for styling.
- Heavy integration. If a component depends on external APIs, you must mock those in the story (e.g., with MSW). A play function that waits on a real network call will be slow and brittle.
There's also a real learning cost: writing robust async play functions takes practice, and tests that depend on timers or animations need explicit waits (waitFor from @storybook/test) rather than fixed sleeps.
Adopting it incrementally
Don't retrofit every story. Pick the five components whose breakage would hurt most — a checkout button, a form with validation, a modal with focus trapping — and write play functions for those first. Because the assertions live in the story file, the people already maintaining your stories are the natural owners, and the Interactions panel gives them visual feedback while they write.
Once the critical paths are covered, wire test:storybook into CI against a built Storybook. From then on, the documentation and the regression suite are the same artifact — and when someone refactors that shared hook, the failure shows up in the pull request, not in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.