Storybook Interaction Testing: A Complete Guide

Master component-level interaction testing with Storybook's play functions and Testing Library integration to verify your UI components behave correctly under real user interactions.

What is Interaction Testing?

Interaction testing verifies that your UI components behave correctly when users interact with them. Unlike visual tests that capture screenshots, interaction tests simulate real user behavior--clicks, typing, keyboard navigation, and more--to ensure components respond accurately to every interaction.

Modern web applications consist of increasingly complex components with sophisticated interactive behavior. A button might need to handle loading states, disabled conditions, and various click scenarios. A form component must manage input validation, error displays, and submission workflows. Interaction tests ensure these behaviors work correctly without requiring full application integration.

Traditional testing approaches have limitations. Unit tests focus on individual functions but miss UI behavior. End-to-end tests cover complete user flows but are slow and brittle. Interaction tests occupy a sweet spot--testing component behavior in isolation while simulating realistic user interactions.

For teams building React applications, component libraries are foundational. Interaction testing these components ensures consistency across your entire application while providing living documentation that evolves with your codebase.

Why Interaction Testing Matters

Storybook's interaction testing system combines several powerful tools to validate component functionality:

  • Play Functions: Define test scenarios directly within your story definitions
  • Testing Library: Simulate user events and make assertions about component state
  • Test Runner: Execute tests in parallel across browsers automatically
  • Interactions Panel: Debug tests step by step to understand exactly what happened

Interaction tests catch bugs that visual tests miss--a button that looks correct but doesn't respond to clicks, a dropdown that opens but doesn't close on outside click, or a form that accepts invalid input. These functional issues directly impact user experience but are invisible to screenshot-based testing.

Setting Up Your Testing Environment

Before writing interaction tests, ensure your project has the necessary dependencies installed. Most modern Storybook installations include interaction testing support by default, but verifying your setup ensures everything works correctly.

Installing Required Dependencies
1# Install Testing Library packages2npm install --save-dev @testing-library/user-event @testing-library/dom3 4# Install Storybook test runner (if not included)5npm install --save-dev @storybook/test-runner6 7# Verify @storybook/test package is available8npm list @storybook/test

Your .storybook/main.ts file should already include Storybook's testing module. The addon-interactions provides the Interactions panel in Storybook's UI, which is essential for debugging tests and understanding interaction sequences:

.storybook/main.ts Configuration
1import type { StorybookConfig } from '@storybook/react-vite';2 3const config: StorybookConfig = {4 stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],5 addons: [6 '@storybook/addon-links',7 '@storybook/addon-essentials',8 '@storybook/addon-interactions',9 ],10 framework: {11 name: '@storybook/react-vite',12 options: {},13 },14 docs: {15 autodocs: 'tag',16 },17};18 19export default config;

Writing Play Functions

Play functions are the core of Storybook interaction testing. They define a sequence of interactions and assertions that run automatically when a story loads. A play function is essentially a test scenario written in code that your team can trust and maintain.

Basic Play Function Example
1import type { Meta, StoryObj } from '@storybook/react';2import { fn } from '@storybook/test';3import { LoginForm } from './LoginForm';4 5const meta: Meta<typeof LoginForm> = {6 title: 'Components/LoginForm',7 component: LoginForm,8 args: {9 onSubmit: fn(),10 },11};12 13export default meta;14type Story = StoryObj<typeof meta>;15 16export const Filled: Story = {17 play: async ({ canvasElement }) => {18 const canvas = within(canvasElement);19 20 // Type email21 await userEvent.type(canvas.getByLabelText('Email'), '[email protected]');22 23 // Type password24 await userEvent.type(canvas.getByLabelText('Password'), 'password123');25 26 // Submit form27 await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));28 },29};

The play function receives a context object containing useful utilities:

  • canvasElement: The DOM element where the component renders
  • canvas: A Testing Library wrapper scoped to the canvas for querying
  • step: Log named steps for better debugging and test organization
  • userEvent: Simulate user interactions like clicks, typing, and keyboard input
  • expect: Make assertions about component state and behavior
Advanced Play Function with Steps
1export const WithValidationErrors: Story = {2 play: async ({ canvasElement, step }) => {3 const canvas = within(canvasElement);4 5 await step('Empty form submission', async () => {6 await userEvent.click(canvas.getByRole('button', { name: /submit/i }));7 8 // Assert error messages appear9 await expect(canvas.getByText('Email is required')).toBeInTheDocument();10 await expect(canvas.getByText('Password must be at least 8 characters')).toBeInTheDocument();11 });12 13 await step('Fill with valid data', async () => {14 await userEvent.clear(canvas.getByLabelText('Email'));15 await userEvent.type(canvas.getByLabelText('Email'), '[email protected]');16 17 await userEvent.clear(canvas.getByLabelText('Password'));18 await userEvent.type(canvas.getByLabelText('Password'), 'securepassword123');19 20 await userEvent.click(canvas.getByRole('button', { name: /submit/i }));21 22 // Assert success state23 await expect(canvas.getByText('Success!')).toBeInTheDocument();24 });25 },26};

Testing Library Integration

Storybook integrates seamlessly with Testing Library, providing familiar APIs for user event simulation and assertions. This integration means you can use the same testing patterns across Storybook, unit tests, and end-to-end tests, reducing context switching for your development team.

For teams implementing custom web solutions, consistent testing patterns across your component library reduce maintenance overhead and improve developer productivity. The same Testing Library approach works whether you're testing isolated components in Storybook or full integration tests.

Testing Library User Event Methods
MethodDescriptionExample
userEvent.click()Simulate mouse clickuserEvent.click(button)
userEvent.dblClick()Simulate double clickuserEvent.dblClick(item)
userEvent.type()Type text into inputuserEvent.type(input, 'text')
userEvent.clear()Clear input valueuserEvent.clear(input)
userEvent.keyboard()Press keyboard keysuserEvent.keyboard('{Enter}')
userEvent.hover()Hover over elementuserEvent.hover(button)
userEvent.upload()Upload filesuserEvent.upload(input, file)
Search Dropdown Interaction Test
1import { userEvent, within, expect } from '@storybook/test';2import { SearchDropdown } from './SearchDropdown';3 4export const WithResults: Story = {5 play: async ({ canvasElement }) => {6 const canvas = within(canvasElement);7 const input = canvas.getByRole('combobox', { name: /search/i });8 9 // Type in search box10 await userEvent.type(input, 'react');11 12 // Wait for dropdown to appear13 const dropdown = await canvas.findByRole('listbox');14 15 // Verify results appear16 await expect(within(dropdown).getByText('React')).toBeInTheDocument();17 await expect(within(dropdown).getByText('React Native')).toBeInTheDocument();18 19 // Select first result20 await userEvent.click(within(dropdown).getByText('React'));21 22 // Verify selection23 await expect(input).toHaveValue('React');24 },25};

The within() function creates a scoped query function limited to a specific container. This is essential when testing nested components or components that render into portals, ensuring your queries don't accidentally match elements outside the component being tested.

Query Priority and Best Practices

Testing Library's guiding principle is to query elements the same way users find them. This means prioritizing semantic, accessible queries over arbitrary attributes or implementation details. Following this principle produces tests that remain stable even when implementation details change.

Testing Library Query Priority
PriorityQuery TypeWhen to Use
1ByRole (with name)Most elements, especially interactive ones
2ByLabelTextForm inputs with visible labels
3ByPlaceholderTextInputs without labels but with placeholder
4ByTextNon-interactive text content
5ByTestIdLast resort, for dynamic content
Query Priority Examples
1// PREFERRED - Accessible, semantic queries2expect(canvas.getByRole('button', { name: /submit/i })).toBeEnabled();3expect(canvas.getByRole('checkbox', { name: /agree to terms/i })).toBeChecked();4expect(canvas.getByRole('combobox', { name: /country/i })).toHaveValue('CA');5 6// AVOID - Implementation-specific queries7expect(canvas.getByTestId('submit-button')).toBeEnabled();8expect(container.querySelector('.btn-primary')).toBeInTheDocument();9expect(container.querySelector('[data-qa="checkbox"]')).toBeChecked();

Testing Library provides three query variants with different behaviors:

  • getBy*: Synchronous, throws if not found (use for elements that should exist immediately)
  • queryBy*: Synchronous, returns null if not found (use for checking absence of elements)
  • findBy*: Asynchronous, waits for element to appear (use for elements that appear after async operations)
Query Variants Example
1export const LoadingStates: Story = {2 play: async ({ canvasElement }) => {3 const canvas = within(canvasElement);4 5 // Initial state - spinner should be visible6 expect(canvas.getByRole('status')).toHaveTextContent('Loading...');7 8 // Wait for loading to complete (findBy is async)9 const content = await canvas.findByRole('article');10 11 // Verify spinner is gone (queryBy returns null, not throws)12 expect(canvas.queryByRole('status')).not.toBeInTheDocument();13 14 // Verify content loaded15 expect(content).toHaveTextContent('Data loaded successfully');16 },17};

Automating with Test Runner

The Storybook Test Runner executes all your play functions automatically, enabling interaction testing at scale. It runs stories as test files, making interaction testing integrate naturally with your existing test infrastructure and CI/CD pipeline.

Package.json Scripts
1{2 "scripts": {3 "test-storybook": "test-storybook"4 }5}
Test Runner Commands
1# Run all interaction tests2npm run test-storybook3 4# Run tests in specific file5npm run test-storybook -- src/components/Button/Button.stories.tsx6 7# Run tests with coverage8npm run test-storybook -- --coverage9 10# Watch mode for development11npm run test-storybook -- --watch12 13# CI mode (exits with error code on failure)14npm run test-storybook -- --ci

CI/CD Integration

Integrating Storybook interaction tests into your CI/CD pipeline ensures component behavior is validated on every commit, preventing regressions from reaching production. This automation catches issues early when they're cheapest to fix.

For enterprise applications with large component libraries, automated testing in CI/CD is essential for maintaining code quality at scale. Each commit triggers your interaction tests, providing immediate feedback to developers.

GitHub Actions Workflow
1name: Interaction Tests2 3on: [push, pull_request]4 5jobs:6 test:7 runs-on: ubuntu-latest8 steps:9 - uses: actions/checkout@v410 11 - name: Setup Node.js12 uses: actions/setup-node@v413 with:14 node-version: '20'15 cache: 'npm'16 17 - name: Install dependencies18 run: npm ci19 20 - name: Build Storybook21 run: npm run build-storybook22 23 - name: Run Interaction Tests24 run: npm run test-storybook -- --ci25 26 - name: Upload test results27 if: failure()28 uses: actions/upload-artifact@v429 with:30 name: test-results31 path: test-results/

Debugging Interaction Tests

Storybook's Interactions panel provides a visual debugger for interaction tests. When a test fails, you can replay the interaction sequence step-by-step, inspect component state at each step, and identify exactly where the test diverges from expected behavior.

Common Debugging Techniques

  • Add console.log statements: Log component state or variable values during test execution
  • Use step() functions: Wrap sections of your play function in named steps for clearer debugging output
  • Increase test timeout: Some async operations may need more time: { timeout: 10000 }
  • Snapshot debugging: Use toJSON() to inspect component tree structure
Debugging Play Function Example
1export const DebuggingExample: Story = {2 play: async ({ canvasElement }) => {3 const canvas = within(canvasElement);4 5 // Log the canvas HTML for debugging6 console.log('Canvas HTML:', canvasElement.innerHTML);7 8 // Use step for named sections9 await step('Initial state', async () => {10 const button = canvas.getByRole('button');11 console.log('Button state:', button.disabled);12 });13 14 await step('After click', async () => {15 const button = canvas.getByRole('button');16 await userEvent.click(button);17 console.log('After click, button disabled:', button.disabled);18 19 // Verify the click worked20 await expect(button).toBeDisabled();21 });22 },23};

Advanced Patterns

When testing components with external dependencies, you'll need to mock API calls, contexts, or external services. Storybook provides several mechanisms for this, allowing you to test components in isolation without relying on real backend services.

API Mocking with MSW
1import { http, HttpResponse } from 'msw';2import { setupServer } from 'msw/node';3 4// Mock server for API responses5const server = setupServer(6 http.get('/api/user', () => {7 return HttpResponse.json({8 id: 1,9 name: 'Test User',10 email: '[email protected]'11 });12 }),13 http.post('/api/orders', async ({ request }) => {14 const body = await request.json();15 return HttpResponse.json({ success: true, orderId: '12345' });16 })17);18 19// Enable API mocking in play function20export const WithMockedAPI: Story = {21 play: async ({ canvasElement }) => {22 // Start mock server before test23 server.listen();24 25 const canvas = within(canvasElement);26 27 // Component will receive mocked data28 await canvas.findByRole('heading', { name: /test user/i });29 30 // Clean up after test31 server.close();32 },33};
Context Providers with Decorators
1import { ThemeProvider } from './ThemeContext';2import { AuthProvider } from './AuthContext';3 4const meta: Meta<typeof Dashboard> = {5 title: 'Components/Dashboard',6 component: Dashboard,7 decorators: [8 (Story) => (9 <ThemeProvider initialTheme="dark">10 <AuthProvider initialUser={{ name: 'Test User', role: 'admin' }}>11 <Story />12 </AuthProvider>13 </ThemeProvider>14 ),15 ],16};17 18export const AdminView: Story = {19 play: async ({ canvasElement }) => {20 const canvas = within(canvasElement);21 22 // Verify admin elements are visible23 await expect(canvas.getByRole('button', { name: /delete/i })).toBeInTheDocument();24 await expect(canvas.getByText('Admin Panel')).toBeInTheDocument();25 },26};

Conclusion

Storybook's interaction testing system provides a powerful framework for validating component behavior. By combining play functions with Testing Library's familiar APIs, you can write comprehensive tests that verify your components work correctly under real-world user interactions.

Key Takeaways

  • Use play functions to define test scenarios directly in your stories, keeping tests and documentation together
  • Follow Testing Library's query priority for accessible, maintainable tests that reflect how users interact with your interface
  • Leverage the Test Runner for automated, parallel test execution that scales with your component library
  • Integrate with CI/CD to catch regressions before they reach production, protecting your users from broken functionality

Start by adding interaction tests to your most critical components--forms, modals, dropdowns, and other interactive elements that users rely on every day. Expand coverage as you develop new features. The investment in writing good interaction tests pays dividends in code quality, documentation value, and developer confidence.

By testing components in isolation with realistic user interactions, you build a safety net that catches bugs early while maintaining living documentation that your team can trust.

Looking to strengthen your overall testing strategy? Our team of experienced developers can help you implement comprehensive web development services including testing frameworks, CI/CD pipelines, and quality assurance processes that protect your investment in custom software.

Need Help Implementing Component Testing?

Our team of experienced developers can help you set up comprehensive testing strategies for your component library, ensuring quality and maintainability as your application grows.

Sources

  1. Storybook Official Documentation - Interaction Testing - Core reference for all interaction testing concepts including play functions, canvas API, and assertion patterns

  2. Chromatic - Interaction Tests for User Behavior - How interaction tests integrate with visual testing workflows, debugging capabilities, and CI/CD integration

  3. BrowserStack - Storybook Test Runner Guide - Detailed guidance on running Storybook tests in CI environments, browser testing considerations, and integration with testing infrastructure

  4. Storybook Blog - Test Component Interactions - Tutorial-style guide covering practical implementation of interaction tests with step-by-step examples

  5. Testing Library - Query Priority - Reference for Testing Library query methods and recommended priority for finding elements in tests