Skip to main content

Storybook Interaction Testing: User Workflows

Storybook's interaction testing feature (via the @storybook/addon-interactions add-on) lets you simulate user actions like clicks, typing, and form submissions directly within a story, then verify that the component responds correctly. Using the play function, you write a sequence of interactions in a story file, and Storybook executes them automatically when the story loads, showing a step-by-step log of each action. This approach bridges the gap between manual visual testing and automated Cypress/Playwright tests, letting you validate complex workflows without leaving Storybook.

What Are Play Functions and How Do You Write Them?

A play function is an async function in your story that simulates user interactions using the @storybook/test library. Here's a simple Login form example:

import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect } from '@storybook/test';
import { LoginForm } from './LoginForm';

const meta = {
component: LoginForm,
tags: ['autodocs'],
} satisfies Meta<typeof LoginForm>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
onSubmit: async (email, password) => {
console.log('Form submitted:', { email, password });
},
},
};

export const FilledForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

// Find and fill the email input
const emailInput = canvas.getByLabelText('Email');
await userEvent.type(emailInput, '[email protected]', { delay: 50 });

// Find and fill the password input
const passwordInput = canvas.getByLabelText('Password');
await userEvent.type(passwordInput, 'securePassword123', { delay: 50 });

// Click the submit button
const submitButton = canvas.getByRole('button', { name: /sign in/i });
await userEvent.click(submitButton);

// Verify success message appears
const successMessage = canvas.queryByText(/signed in successfully/i);
await expect(successMessage).toBeInTheDocument();
},
};

The play function receives canvasElement (the root DOM node of the rendered story) and uses Testing Library queries (getByLabelText, getByRole) to find elements. Storybook executes the function automatically and logs each step in the Interactions tab.

Step-by-Step User Actions with userEvent

The userEvent utility from @storybook/test simulates realistic user behavior—it types characters with delays, clicks with proper focus handling, and fires all the appropriate events. Here's a more complex example with a multi-step form:

export const CompleteWorkflow: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

// Step 1: Fill email
const emailInput = canvas.getByPlaceholderText('Email');
await userEvent.type(emailInput, '[email protected]', { delay: 50 });

// Step 2: Fill password
const passwordInput = canvas.getByPlaceholderText('Password');
await userEvent.type(passwordInput, 'pass123', { delay: 50 });

// Step 3: Check "remember me"
const rememberCheckbox = canvas.getByRole('checkbox', { name: /remember me/i });
await userEvent.click(rememberCheckbox);

// Step 4: Submit
const submitButton = canvas.getByRole('button', { name: /sign in/i });
await userEvent.click(submitButton);

// Step 5: Wait for and verify redirect or success state
const successAlert = canvas.queryByRole('alert');
if (successAlert) {
await expect(successAlert).toHaveTextContent(/welcome/i);
}
},
};

Each userEvent call (type, click, hover) is logged in the Interactions tab with timing, so you can debug slow interactions or missing events.

Waiting for Async State Changes

Many React components handle async operations (API calls, timers). Use waitFor from Testing Library to pause the play function until an expected state appears:

import { userEvent, within, expect, waitFor } from '@storybook/test';

export const AsyncForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

// Fill form
const emailInput = canvas.getByLabelText('Email');
await userEvent.type(emailInput, '[email protected]');

// Submit
const submitButton = canvas.getByRole('button', { name: /submit/i });
await userEvent.click(submitButton);

// Wait for loading state to appear
const loadingSpinner = canvas.queryByRole('progressbar');
if (loadingSpinner) {
await waitFor(() => {
expect(loadingSpinner).not.toBeInTheDocument();
}, { timeout: 5000 });
}

// Verify success message
const successMessage = canvas.getByText(/submission successful/i);
await expect(successMessage).toBeInTheDocument();
},
};

The waitFor function polls the DOM up to 5 seconds for the condition to become true. This handles components that take time to update (spinners, API responses).

Organizing Interactions into Reusable Sequences

For large components with repeated interaction patterns, extract sequences into helper functions:

async function fillLoginForm(canvas: ReturnType<typeof within>, email: string, password: string) {
const emailInput = canvas.getByLabelText('Email');
await userEvent.type(emailInput, email, { delay: 50 });

const passwordInput = canvas.getByLabelText('Password');
await userEvent.type(passwordInput, password, { delay: 50 });
}

async function submitForm(canvas: ReturnType<typeof within>) {
const button = canvas.getByRole('button', { name: /sign in/i });
await userEvent.click(button);
}

export const ValidCredentials: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillLoginForm(canvas, '[email protected]', 'correct-password');
await submitForm(canvas);

const successMessage = canvas.getByText(/welcome/i);
await expect(successMessage).toBeInTheDocument();
},
};

export const InvalidCredentials: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillLoginForm(canvas, '[email protected]', 'wrong-password');
await submitForm(canvas);

const errorMessage = canvas.getByText(/invalid credentials/i);
await expect(errorMessage).toBeInTheDocument();
},
};

This pattern keeps stories readable and DRY. Both test valid and invalid workflows using the same helper functions.

Testing Keyboard Navigation and Accessibility

Play functions can simulate keyboard events for testing focus management and keyboard navigation:

export const KeyboardNavigation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

// Tab to first input
await userEvent.tab();
const emailInput = canvas.getByLabelText('Email');
await expect(emailInput).toHaveFocus();

// Type in focused input
await userEvent.keyboard('[email protected]');

// Tab to password input
await userEvent.tab();
const passwordInput = canvas.getByLabelText('Password');
await expect(passwordInput).toHaveFocus();

// Type password
await userEvent.keyboard('password123');

// Tab to button and press Enter
await userEvent.tab();
const submitButton = canvas.getByRole('button', { name: /sign in/i });
await expect(submitButton).toHaveFocus();
await userEvent.keyboard('{Enter}');

// Verify form submitted
const success = canvas.getByText(/signed in/i);
await expect(success).toBeInTheDocument();
},
};

Using userEvent.tab() and userEvent.keyboard() tests keyboard-only navigation, critical for accessibility.

Integrating Play Functions with Controls

Combine play functions with Controls to test prop variations interactively:

export const DynamicInteraction: Story = {
args: {
placeholder: 'Enter text...',
maxLength: 50,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);

// Type a message respecting maxLength
const input = canvas.getByPlaceholderText(args.placeholder);
const testText = 'a'.repeat(Math.min(30, args.maxLength));
await userEvent.type(input, testText);

// Verify character count
const count = canvas.getByText(new RegExp(`${testText.length}`));
await expect(count).toBeInTheDocument();
},
};

Users can modify placeholder or maxLength via Controls, and the play function adapts dynamically. This tests component flexibility without writing multiple stories.

Key Takeaways

  • Play functions automate user interactions: Define async functions that simulate clicks, typing, and navigation using userEvent and Testing Library queries.
  • Interactions are logged visually: Each action is logged in the Interactions tab with timing, making debugging easy.
  • WaitFor handles async state: Use waitFor to pause until async operations (API calls, spinners) complete.
  • Extract helpers for reusability: Create helper functions for repeated interaction patterns (form filling, submission) to keep stories DRY.
  • Keyboard navigation is testable: Use userEvent.tab() and userEvent.keyboard() to test focus management and keyboard-only workflows.
  • Combine with Controls for flexibility: Play functions can read args from Controls, allowing interactive prop variation and interaction testing simultaneously.

Frequently Asked Questions

Can I use play functions with mocked APIs or MSW?

Yes. Set up MSW handlers in your story's loaders parameter before the play function executes. MSW mocks API calls globally, so your play function doesn't need to know about it. The play function proceeds as if the API returned real data.

How do I handle multiple play function sequences in one story?

Use nested helper functions or chain awaits. If you need different sequences based on user actions, extract them into helper functions and call them conditionally in the play function. However, a single story should represent one user journey; create multiple stories for multiple workflows.

Does Storybook run play functions in the CI pipeline?

Not by default. Play functions run in the browser UI. To run them in CI, use Chromatic's visual testing service or Storybook's test-runner CLI, which headlessly executes play functions and verifies assertions.

What's the difference between play functions and Cypress/Playwright tests?

Play functions are lightweight and integrated into Storybook's UI—great for rapid manual verification and documentation of user workflows. Cypress/Playwright are more powerful for end-to-end testing across multiple pages and integrating with backend services. Use both: play functions for isolated component workflows, E2E tests for full-page flows.

Further Reading