Storybook 8 Testing: play Functions, Portable Stories, and Vitest Integration

Storybook 8 Testing: play Functions, Portable Stories, and Vitest Integration

Storybook 8 expanded from a documentation tool into a component testing platform. The key additions are play functions (interaction tests that run inside the browser), portable stories (importing stories into Vitest or Jest), and the Storybook test runner that executes all play functions in CI. Together they let you write tests once and run them both in the browser (for debugging) and in headless CI.

Play Functions

A play function runs after a story renders. It uses @storybook/test (which wraps Vitest's expect and @testing-library/user-event) to simulate user interactions and assert on results:

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

const meta: Meta<typeof LoginForm> = {
  component: LoginForm,
};
export default meta;
type Story = StoryObj<typeof LoginForm>;

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

    // Click submit without filling the form
    await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));

    // Assert validation errors appear
    await expect(canvas.getByText('Email is required')).toBeInTheDocument();
    await expect(canvas.getByText('Password is required')).toBeInTheDocument();
  },
};

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

    await userEvent.type(canvas.getByLabelText('Email'), 'wrong@example.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'wrongpassword');
    await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));

    await expect(
      canvas.getByText('Invalid email or password')
    ).toBeInTheDocument();
  },
};

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

    await userEvent.type(canvas.getByLabelText('Email'), 'alice@example.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'correct-password');
    await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));

    await expect(canvas.getByText('Welcome back, Alice')).toBeInTheDocument();
  },
};

In the Storybook UI, play functions run when the story loads and show pass/fail results in the Interactions panel. Failed assertions highlight the specific step and show the error.

Args and Loaders for Test Setup

Use args to configure mock behavior, and loaders for async setup:

export const WithMockedAPI: Story = {
  args: {
    onLogin: async (credentials: Credentials) => {
      // Simulated API response
      if (credentials.email === 'alice@example.com') {
        return { user: { name: 'Alice', role: 'admin' } };
      }
      throw new Error('Invalid credentials');
    },
  },
};

export const WithSlowNetwork: Story = {
  loaders: [
    async () => ({
      // Simulate slow API
      delay: await new Promise(resolve => setTimeout(resolve, 2000)),
    }),
  ],
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // Check loading state is shown during the delay
    await expect(canvas.getByText('Signing in...')).toBeInTheDocument();
  },
};

Portable Stories with Vitest

Storybook 8 introduced portable stories: import stories into Vitest (or Jest) and render them with Testing Library. This lets you run interaction tests outside the browser:

// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import { composeStory } from '@storybook/react';
import meta, { EmptySubmit, SuccessfulLogin } from './LoginForm.stories';

// Compose the story with its meta (applies decorators, args, etc.)
const EmptySubmitStory = composeStory(EmptySubmit, meta);
const SuccessfulLoginStory = composeStory(SuccessfulLogin, meta);

test('empty submit shows validation errors', async () => {
  const { container } = render(<EmptySubmitStory />);
  // play function runs automatically when using composeStory
  await EmptySubmitStory.play({ canvasElement: container });
  expect(screen.getByText('Email is required')).toBeInTheDocument();
});

test('successful login shows welcome message', async () => {
  const { container } = render(<SuccessfulLoginStory />);
  await SuccessfulLoginStory.play({ canvasElement: container });
  expect(screen.getByText('Welcome back, Alice')).toBeInTheDocument();
});

composeStory applies decorators, default args, and meta configuration — the story renders exactly as it does in Storybook.

Vitest Plugin (Storybook 8.1+)

For deeper Vitest integration, use the Storybook Vitest plugin. It runs stories as Vitest test files without a browser:

npm install --save-dev @storybook/experimental-addon-test

vitest.config.ts:

import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin';

export default defineConfig({
  plugins: [
    storybookTest({
      configDir: '.storybook',
    }),
  ],
  test: {
    browser: {
      enabled: true,
      name: 'chromium',
      provider: 'playwright',
    },
    setupFiles: ['.storybook/vitest.setup.ts'],
  },
});

.storybook/vitest.setup.ts:

import { setProjectAnnotations } from '@storybook/react';
import * as projectAnnotations from './preview';

setProjectAnnotations(projectAnnotations);

With this setup, npx vitest discovers and runs all story files as tests. Play functions execute in Playwright's browser context, giving you the full DOM environment.

Storybook Test Runner

The test runner uses Playwright to open each story in a headless browser and run its play function:

npm install --save-dev @storybook/test-runner
# Run all story tests
npx test-storybook

<span class="hljs-comment"># Run specific stories
npx test-storybook --testPathPattern LoginForm

GitHub Actions:

- name: Run Storybook tests
  run: |
    npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
      "npx storybook dev --port 6006 --quiet" \
      "npx wait-on tcp:6006 && npx test-storybook"

The test runner also runs accessibility checks if the a11y addon is installed — each story gets an automatic axe scan.

Accessibility Testing in Stories

// Button.stories.tsx
export const IconOnly: Story = {
  render: () => <Button icon="close" />,  // Missing aria-label
  parameters: {
    a11y: {
      // Configure axe rules
      config: {
        rules: [{ id: 'button-name', enabled: true }],
      },
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // Storybook test runner runs axe on this story automatically
    // The missing aria-label will cause an a11y failure
  },
};

Storybook 8 Performance

Storybook 8 switches from Webpack to Vite by default for new projects. For existing Webpack projects, migrate:

npx storybook@latest upgrade

With Vite, cold start drops from 30–90 seconds to 2–8 seconds for most projects.

Story-Driven Test Strategy

The Storybook 8 testing model:

  1. Write stories for all meaningful component states
  2. Add play functions for interaction tests (clicks, form submission, keyboard navigation)
  3. Run in browser during development for visual feedback
  4. Run in CI with test runner for automated regression detection
  5. Import as portable stories in Vitest for unit test pipelines that don't require a browser

This keeps component tests in one place (stories) and removes the duplication between Storybook stories and separate test files.

Key Points

  • play functions use @storybook/test (userEvent, expect, within) to simulate interactions in the rendered story
  • composeStory imports stories into Vitest/Jest with full decorator and arg resolution
  • The Storybook test runner uses Playwright to run all play functions headlessly in CI
  • Storybook 8 defaults to Vite — cold start drops from minutes to seconds
  • The Vitest plugin (@storybook/experimental-addon-test) runs stories as Vitest tests in a browser context
  • a11y addon + test runner automatically runs axe on every story in CI
  • Use loaders for async test setup and args to configure mock behavior per story

Read more