Automate UI Logic Tests with Storybook in 20 Minutes

Test user interactions like clicks and form submissions directly in Storybook using @storybook/test and the play function.

Problem: Manual Testing Wastes Your Time

You open Storybook, click buttons, fill forms, and verify outputs manually every time you change component logic. This takes 5-10 minutes per component and you forget edge cases.

You'll learn:

  • How to write interaction tests in story files
  • Test clicks, inputs, and async behavior automatically
  • Run tests in CI without extra test infrastructure

Time: 20 min | Level: Intermediate


Why This Happens

Storybook shows components in isolation, but you still need to interact with them manually to verify behavior. Traditional testing tools like Jest require duplicating your component setup code.

Common symptoms:

  • Clicking through the same flows repeatedly
  • Bugs slip through because manual testing is inconsistent
  • Jest tests don't match how components actually render in Storybook

Solution

Step 1: Install Testing Dependencies

Storybook 8+ includes interaction testing built-in. Install the test utilities:

npm install @storybook/test --save-dev

Expected: Package installs without peer dependency warnings

If it fails:

  • Error: "Cannot find @storybook/test": Update to Storybook 8.0+
  • Peer dependency issues: Run npm install @storybook/addon-interactions --save-dev

Step 2: Write Your First Interaction Test

Here's a login form component that needs testing:

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

const meta: Meta<typeof LoginForm> = {
  title: 'Forms/LoginForm',
  component: LoginForm,
};

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

// Basic story shows initial state
export const Default: Story = {};

// Interaction test simulates user behavior
export const SuccessfulLogin: Story = {
  play: async ({ canvasElement }) => {
    // Query elements within this story's canvas
    const canvas = within(canvasElement);
    
    // Simulate user typing
    const emailInput = canvas.getByLabelText('Email');
    await userEvent.type(emailInput, 'user@example.com');
    
    const passwordInput = canvas.getByLabelText('Password');
    await userEvent.type(passwordInput, 'password123');
    
    // Click submit
    const submitButton = canvas.getByRole('button', { name: /sign in/i });
    await userEvent.click(submitButton);
    
    // Verify outcome - success message appears
    await expect(canvas.getByText('Welcome back!')).toBeInTheDocument();
  },
};

Why this works: The play function runs after the component renders. within(canvasElement) scopes queries to this specific story, preventing cross-story conflicts.


Step 3: Test Error States

Add tests for validation failures:

export const InvalidEmail: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.type(canvas.getByLabelText('Email'), 'not-an-email');
    await userEvent.type(canvas.getByLabelText('Password'), 'pass');
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
    
    // Error message should appear without navigation
    await expect(canvas.getByText('Please enter a valid email')).toBeInTheDocument();
  },
};

export const EmptyFields: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Click submit without filling fields
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
    
    // Both field errors should show
    await expect(canvas.getByText('Email is required')).toBeInTheDocument();
    await expect(canvas.getByText('Password is required')).toBeInTheDocument();
  },
};

Expected: Each story runs independently. Errors display correctly without affecting other tests.

If it fails:

  • Error: "Element not found": Check label text matches exactly (case-sensitive)
  • Test passes but UI is wrong: Add await before expect to wait for async updates

Step 4: Test Async Behavior

Components often fetch data or show loading states:

export const LoadingState: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'password123');
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
    
    // Loading spinner appears immediately
    expect(canvas.getByText('Signing in...')).toBeInTheDocument();
    
    // Wait for async operation to complete
    await expect(canvas.findByText('Welcome back!')).resolves.toBeInTheDocument();
    
    // Spinner should be gone
    expect(canvas.queryByText('Signing in...')).not.toBeInTheDocument();
  },
};

Why findBy: It waits up to 1 second for the element to appear, handling async state changes. Use getBy for immediate checks, findBy for elements that appear later.


Step 5: Mock API Calls

Real API calls make tests slow and flaky. Mock them in stories:

import { http, HttpResponse } from 'msw';

export const NetworkError: Story = {
  parameters: {
    msw: {
      handlers: [
        http.post('/api/login', () => {
          // Simulate server error
          return HttpResponse.json(
            { error: 'Server unavailable' },
            { status: 503 }
          );
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'password123');
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
    
    // Network error message displays
    await expect(canvas.findByText('Server unavailable')).resolves.toBeInTheDocument();
  },
};

Setup required: Install MSW for Storybook:

npm install msw msw-storybook-addon --save-dev
npx msw init public/

Add to .storybook/preview.ts:

import { initialize, mswLoader } from 'msw-storybook-addon';

initialize();

export const loaders = [mswLoader];

Step 6: Run Tests in CI

Interaction tests run automatically with Storybook's test runner:

npm install @storybook/test-runner --save-dev

Add to package.json:

{
  "scripts": {
    "test:storybook": "test-storybook"
  }
}

GitHub Actions workflow:

name: Storybook Tests
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      
      - run: npm ci
      - run: npm run build-storybook
      
      # Run tests against static build
      - run: npx http-server storybook-static --port 6006 &
      - run: npm run test:storybook

Why static build: Tests run faster against pre-built Storybook. Dev server works too but adds startup time.


Verification

Start Storybook and open the Interactions panel:

npm run storybook

You should see:

  1. All stories render correctly
  2. Interactions panel shows step-by-step test execution
  3. Green checkmarks for passing assertions
  4. Test reruns automatically when you edit code

Run tests in Terminal:

npm run test:storybook

Expected output:

âœ" Forms/LoginForm: Successful Login (543ms)
âœ" Forms/LoginForm: Invalid Email (312ms)
âœ" Forms/LoginForm: Empty Fields (289ms)
âœ" Forms/LoginForm: Loading State (678ms)
âœ" Forms/LoginForm: Network Error (445ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total

What You Learned

  • The play function automates interactions that you'd do manually
  • within(canvasElement) prevents test conflicts between stories
  • Use findBy queries for async elements, getBy for immediate checks
  • MSW mocks API calls so tests run fast and reliably

Limitation: Interaction tests run in the browser, so they don't check server-side rendering behavior. Use Playwright or Cypress for full E2E flows.

When NOT to use this:

  • Testing business logic (use Jest instead)
  • Multi-page flows (use E2E tests)
  • Accessibility audits (use axe-core addon)

Common Patterns

Testing Form Validation

export const PasswordTooShort: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.type(canvas.getByLabelText('Password'), 'abc');
    await userEvent.tab(); // Trigger blur validation
    
    await expect(canvas.getByText('Password must be at least 8 characters'))
      .toBeInTheDocument();
  },
};

Testing Keyboard Navigation

export const KeyboardSubmit: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    const emailInput = canvas.getByLabelText('Email');
    await userEvent.type(emailInput, 'user@example.com{Tab}');
    await userEvent.type(canvas.getByLabelText('Password'), 'password123{Enter}');
    
    // Form submits via Enter key
    await expect(canvas.findByText('Welcome back!')).resolves.toBeInTheDocument();
  },
};

Testing Conditional Rendering

export const ShowPasswordToggle: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    const passwordInput = canvas.getByLabelText('Password') as HTMLInputElement;
    expect(passwordInput.type).toBe('password'); // Hidden initially
    
    const toggleButton = canvas.getByLabelText('Show password');
    await userEvent.click(toggleButton);
    
    expect(passwordInput.type).toBe('text'); // Now visible
  },
};

Debugging Failed Tests

Add delays to see what's happening:

export const DebugExample: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.type(canvas.getByLabelText('Email'), 'test@example.com', {
      delay: 100, // Type slower for debugging
    });
    
    // Pause execution to inspect state
    await canvas.getByRole('button').click();
    await new Promise(resolve => setTimeout(resolve, 2000));
  },
};

Check the Interactions panel's step-by-step breakdown to see exactly where tests fail.


Troubleshooting

"Cannot find element" errors:

  • Use canvas.debug() to print current DOM
  • Check if element renders conditionally
  • Verify label text matches exactly (case-sensitive)

Tests pass but UI looks broken:

  • Tests verify logic, not visuals
  • Add visual regression tests with Chromatic
  • Check browser console for React warnings

Slow test execution:

  • Avoid real API calls (use MSW mocks)
  • Don't use fixed delays (await sleep(1000))
  • Use findBy queries instead of waitFor loops

Tests fail in CI but pass locally:

  • Check Node.js version matches (22.x recommended)
  • Ensure static build completes before running tests
  • Increase timeout for slower CI machines: test-storybook --maxWorkers=2

Tested on Storybook 8.4, React 19, Node.js 22.x, macOS & Ubuntu