Storybook Interaction Testing: Click, Type, Assert in Stories
Learn how to write Storybook interaction tests that click, type, and assert right inside your stories — no separate test files needed.
What Storybook Interaction Testing Actually Is
Honestly, most developers treat Storybook as a visual catalogue and nothing more. You build a story, you eyeball it in the browser, you move on. That works fine until your button stops working after a refactor and nobody notices for three days.
Storybook 6.4 shipped the @storybook/addon-interactions addon along with a play function API that changed things significantly. You can now write interaction tests that live directly inside your story files. Click events, keyboard input, assertions — all of it runs in the Storybook canvas, visible and debuggable without spinning up a separate test runner.
The key distinction is that these aren't unit tests in a *.test.tsx file you run in isolation. They're co-located with your visual stories, so you see the component render, then watch the interactions execute step-by-step in the Interactions panel. It's a completely different debugging experience.
Setting Up the Interactions Addon in Storybook 8
If you're on Storybook 8.x (which you should be — 7 reached maintenance mode in early 2026), the interactions addon ships with the default install for React, Vue, and Svelte frameworks. Run npx storybook@latest init on a fresh project and it's already wired up.
For existing projects that need the addon explicitly, install these two packages: @storybook/addon-interactions and @storybook/test. The second package re-exports Testing Library's user-event at version 14.5.2 and Vitest's expect — so you're not pulling in separate dependencies.
Add the addon to your .storybook/main.ts config:
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions', // add this
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;That's the full setup. No extra config files, no jest.config.js gymnastics. If you're already using the tailwind-shadows-generator tool to build components, you can start writing interaction tests against those components immediately after this step.
Writing Your First play Function
The play function is an async function attached to a story that runs after the component mounts. Storybook passes it a canvasElement argument — that's the DOM node wrapping your rendered story. You use Testing Library's within() to scope your queries to that element.
Here's a real example for a controlled search input component:
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { SearchInput } from './SearchInput';
const meta: Meta<typeof SearchInput> = {
component: SearchInput,
title: 'Forms/SearchInput',
};
export default meta;
type Story = StoryObj<typeof SearchInput>;
export const TypeAndClear: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox', { name: /search/i });
// Type with a realistic 50ms delay between keystrokes
await userEvent.type(input, 'glassmorphism', { delay: 50 });
await expect(input).toHaveValue('glassmorphism');
// Clear the field
await userEvent.clear(input);
await expect(input).toHaveValue('');
},
};Notice the delay: 50 on userEvent.type. Without it, the typing is instantaneous and can miss debounced handlers. 50ms is usually enough to trigger real-world onChange logic without making the test feel slow.
Testing Click Interactions and State Changes
Click testing is where interaction tests shine over static visual checks. You can verify that a modal opens, a dropdown renders its options, a toggle switches state — all without mocking anything at the DOM level.
Think about a theme toggle component in React. You click it, the data-theme attribute flips from light to dark, and downstream CSS variables change. Here's how you'd assert that:
export const TogglesTheme: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: /toggle theme/i });
// Assert initial state
await expect(button).toHaveAttribute('aria-pressed', 'false');
// Click it
await userEvent.click(button);
// Assert updated state
await expect(button).toHaveAttribute('aria-pressed', 'true');
// Click again to verify it toggles back
await userEvent.click(button);
await expect(button).toHaveAttribute('aria-pressed', 'false');
},
};The Interactions panel in Storybook lets you replay each step individually. You click the rewind icon and it walks through your assertions one at a time. This is genuinely useful when something fails — you don't just get a stack trace, you see the component in the exact state it was in when the assertion broke.
Running Interaction Tests in CI with the Test Runner
Playing stories in a browser is great for development. But you want these tests in CI. Storybook ships a CLI test runner (@storybook/test-runner v0.19.x as of late 2026) that uses Playwright under the hood to execute every story's play function headlessly.
Install it: npm install --save-dev @storybook/test-runner. Add a script to package.json:
{
"scripts": {
"test-storybook": "test-storybook",
"test-storybook:ci": "test-storybook --url http://localhost:6006"
}
}In CI, you build Storybook first (storybook build), serve it with something like serve -s storybook-static -p 6006, then run test-storybook:ci. The runner finds every story with a play function and executes it. Stories without play functions are checked for rendering errors at minimum — you get crash detection for free.
One thing to watch: the test runner respects storyStoreV7 and CSF3 format. If you're still on old CSF2 stories with storiesOf(), those won't be picked up correctly. Migration is worth it.
Mocking Args and Network Requests in play Functions
Real components call APIs. You don't want your Storybook tests making HTTP requests to production. Storybook integrates with MSW (Mock Service Worker) via msw-storybook-addon to intercept fetch and XHR calls at the service worker level.
For simpler cases — like testing a form submission handler — you can mock at the args level. Pass a mock function as a prop and assert it was called with the right arguments:
export const SubmitsForm: Story = {
args: {
onSubmit: fn(), // fn() from @storybook/test, wraps vi.fn()
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByLabelText(/email/i),
'user@example.com'
);
await userEvent.type(
canvas.getByLabelText(/password/i),
'hunter2'
);
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
await expect(args.onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'hunter2',
});
},
};The fn() helper from @storybook/test is spy-aware — Storybook resets it between story renders automatically, so you don't get stale call counts leaking between tests. This is something Vitest's vi.fn() doesn't do for you in this context.
Accessibility Assertions Inside play Functions
Here's the thing: accessibility checks don't have to live in separate axe-core runs disconnected from your component stories. You can assert accessible markup inline as part of interaction tests. The @storybook/test package includes @testing-library/jest-dom matchers, which cover most of what you'd need.
After you click a button that opens a modal dialog, assert that focus moved to the modal heading. That's real accessibility behavior, not just aria attribute presence:
export const ModalFocusManagement: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /open modal/i }));
// Dialog should be in the DOM
const dialog = canvas.getByRole('dialog');
await expect(dialog).toBeVisible();
// Focus should move to the heading inside the dialog
const heading = within(dialog).getByRole('heading', { level: 2 });
await expect(heading).toHaveFocus();
// Escape should close it
await userEvent.keyboard('{Escape}');
await expect(dialog).not.toBeInTheDocument();
},
};If you're building components that use visual effects — like the styles from our glassmorphism generator — those components still need to be keyboard-navigable. Interaction tests make it easy to verify that without switching tools. Why write the same component fixture in three different places when one story file can do it all?
Organizing Stories for Testability
Story organization matters more once you're writing play functions. The pattern that works well is one story per user flow, not one story per visual variant. Visual variants can live in a Variants story with no play function. Behavioral flows each get their own named story with a play function describing exactly what it tests.
Name stories like test descriptions: TypesAndSubmits, ShowsValidationError, DisabledWhenLoading. When the CI test runner lists failures, you see Forms/SearchInput/TypesAndSubmits failed and immediately know where to look. Compare that to debugging a story named Default — useless.
Keep play functions focused. If you're testing a multi-step wizard, it's tempting to write one giant play function that clicks through all five steps. Don't. Write five stories, each starting from the appropriate initial state via args. If a step-3 assertion fails, you want to open that story directly and see the component at step 3, not replay 47 user events to get there.
The interaction testing story also pairs well with tracking visual regressions — if you're using tools like Chromatic, it captures a screenshot at the end of every play function, meaning you get visual and behavioral regression coverage from a single story. That's the actual payoff of keeping everything in one place.
FAQ
In the Storybook UI, it runs in the real browser — Chrome, Firefox, or Safari, depending on what you have open. When you run tests with @storybook/test-runner in CI, it uses Playwright's Chromium under the hood, so it's still a real browser environment, not jsdom. This matters for testing things like CSS focus styles, scroll behavior, and custom elements.
You can, but don't. The userEvent exported from @storybook/test is pre-configured to work with Storybook's async rendering and story lifecycle. Importing directly from @testing-library/user-event v14 can cause timing issues because Storybook needs to await certain internal promises between interactions. Stick to the re-export.
Use msw-storybook-addon with MSW 2.x. Define your handlers at the story level using the parameters.msw.handlers array. The service worker intercepts the request before it hits the network, so tests work offline and in CI without any environment variables pointing to a backend.
Nine times out of ten it's a timing issue. The CI machine is slower, so animations or transitions that complete before an assertion in dev don't finish in time on CI. Wrap assertions in waitFor() from @storybook/test, or pass a timeout option to expect. Also check that you're awaiting every userEvent call — missing an await causes the next assertion to run before the event has processed.
Yes. Add parameters: { testRunner: { skip: true } } to the story object. This tells the test runner to skip that story entirely. Useful for stories that are known broken while you're mid-refactor, or visual-only stories where you deliberately don't want behavioral testing.
Use the beforeEach hook available on the story meta object in Storybook 8.2+. You can also extract common interaction sequences into plain async functions and call them from multiple play functions — they're just JavaScript. For example, a loginFlow(canvas) helper that fills in email and password can be imported and reused across any story that needs an authenticated state.