Storybook + Vitest: Running Stories as Component Tests

Storybook + Vitest: Running Stories as Component Tests

Storybook 8.3 introduced a significant integration with Vitest: the ability to run your story play functions as Vitest test cases, using the full Vitest ecosystem for test running, watch mode, filtering, and CI reporting. This changes the calculus for teams choosing between test-runner, Vitest, and Playwright component testing.

This guide covers the Vitest integration in depth — the @storybook/experimental-addon-test addon, the portable stories API for writing component tests outside Storybook, and a direct comparison of the three testing approaches so you can choose the right tool for your situation.

The Portable Stories API

Before the Storybook 8.3 integration, there was already a way to use stories in unit tests: the portable stories API. It's worth understanding this first because the Vitest addon builds on it.

The portable stories API exports two utilities: composeStory for using individual stories and composeStories for using all stories from a file.

Install the package:

npm install --save-dev @storybook/react

Using composeStories in a Vitest test:

// Button.test.ts
import { describe, it, expect } from 'vitest';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import * as stories from './Button.stories';

const { Primary, Disabled, WithIcon } = composeStories(stories);

describe('Button', () => {
  it('renders primary variant', () => {
    render(<Primary />);
    expect(screen.getByRole('button')).toHaveClass('btn-primary');
  });

  it('is disabled when disabled prop is set', () => {
    render(<Disabled />);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

The composeStories function applies all the story's decorators, args, and parameters, so the component renders in exactly the same context as it does in Storybook. This is the main advantage over writing plain Vitest tests: you reuse the setup work you've already done in your stories.

Using composeStory for Single Stories

composeStory is useful when you want to use a single story or override its args for a specific test:

// Form.test.ts
import { composeStory } from '@storybook/react';
import * as FormStories from './Form.stories';

// Compose a specific story
const SubmitForm = composeStory(FormStories.Default, FormStories.default);

// Override args for a variant
const PrefilledForm = composeStory(
  FormStories.Default,
  FormStories.default,
  {
    args: {
      initialValues: {
        email: 'prefilled@example.com',
        name: 'John Doe',
      },
    },
  }
);

This pattern is particularly useful when you need many arg combinations that don't all need to be stories in Storybook's UI.

Running Play Functions in Vitest

The portable stories API also executes play functions. After rendering, call story.play():

// LoginForm.test.ts
import { describe, it, expect } from 'vitest';
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import * as stories from './LoginForm.stories';

const { SuccessfulLogin, ValidationErrors } = composeStories(stories);

describe('LoginForm play functions', () => {
  it('shows welcome message after successful login', async () => {
    const { container } = render(<SuccessfulLogin />);

    // Execute the play function
    await SuccessfulLogin.play({ canvasElement: container });

    // The play function's assertions run inside it,
    // but you can also add assertions here
    expect(container.querySelector('.welcome-message')).toBeTruthy();
  });

  it('shows validation errors on empty submit', async () => {
    const { container } = render(<ValidationErrors />);
    await ValidationErrors.play({ canvasElement: container });
  });
});

When the play function's expect calls fail, they throw and fail the Vitest test. This means your story play functions become your test assertions — write them once, run them in both Storybook UI and Vitest.

The @storybook/experimental-addon-test Addon

Storybook 8.3 introduced @storybook/experimental-addon-test, which automates the boilerplate of the portable stories approach and integrates directly into the Vitest UI plugin:

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

Register it in your Storybook config:

// .storybook/main.ts
const config: StorybookConfig = {
  addons: [
    '@storybook/addon-essentials',
    '@storybook/experimental-addon-test',
  ],
};

Configure Vitest to use the Storybook plugin:

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

export default defineConfig({
  plugins: [
    storybookTest({
      // Path to your Storybook config directory
      configDir: '.storybook',
    }),
  ],
  test: {
    browser: {
      enabled: true,
      name: 'chromium',
      provider: 'playwright',
    },
    // Story files are automatically discovered
    include: ['**/*.stories.?(m)[jt]s?(x)'],
    setupFiles: ['.storybook/vitest.setup.ts'],
  },
});

Create the setup file:

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

// Apply global decorators and parameters from preview.ts
setProjectAnnotations(projectAnnotations);

With this configuration, running npx vitest will automatically discover all your story files and run each story as a test. Stories with play functions will execute the play function. Stories without play functions will be smoke-tested (render without errors).

Vitest UI Integration

The addon adds Storybook integration to the Vitest UI browser interface. Run:

npx vitest --ui

You get:

  • All stories listed as individual test cases
  • Pass/fail status per story
  • Click-to-run individual stories
  • Watch mode — stories automatically rerun when component files change
  • Inline error reporting in the Storybook iframe

This is significantly faster than the test-runner workflow for development, because Vitest's watch mode only reruns affected tests when files change, and you see failures immediately without rebuilding Storybook.

Workspace Configuration for Separate Test Suites

If you want to run story tests separately from your regular unit tests, use Vitest's workspace configuration:

// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';

export default defineWorkspace([
  // Regular unit tests
  {
    extends: 'vitest.config.ts',
    test: {
      name: 'unit',
      environment: 'node',
      include: ['src/**/*.test.ts'],
      exclude: ['src/**/*.stories.ts'],
    },
  },
  // Storybook component tests
  {
    extends: 'vitest.config.ts',
    plugins: [
      storybookTest({ configDir: '.storybook' }),
    ],
    test: {
      name: 'storybook',
      browser: {
        enabled: true,
        name: 'chromium',
        provider: 'playwright',
      },
      include: ['**/*.stories.?(m)[jt]s?(x)'],
      setupFiles: ['.storybook/vitest.setup.ts'],
    },
  },
]);

Run them separately in CI:

npx vitest run --project unit
npx vitest run --project storybook

Comparing the Three Testing Approaches

Once you have Storybook, you have three options for automated component testing. Here is how they compare:

@storybook/test-runner

The test-runner runs stories in a full Playwright browser, navigating to each story URL and executing play functions.

Strengths:

  • Zero additional configuration — works with existing stories immediately
  • Real browser navigation (Chromium via Playwright)
  • Supports preVisit/postVisit hooks for cross-cutting concerns (auth, a11y checks)
  • Parallelizable via Jest shards
  • MSW service workers work without any additional setup

Weaknesses:

  • Requires a running Storybook (or built Storybook + static server)
  • Slower startup time compared to Vitest
  • Watch mode is not as fast as Vitest's incremental watch
  • No integration with Vitest ecosystem (coverage, reporters, etc.)

Best for: CI pipelines where you want comprehensive story testing with minimal setup, teams already using Jest for other tests.

Vitest + @storybook/experimental-addon-test

Runs stories as Vitest tests, in a Vitest-managed browser context.

Strengths:

  • Fastest watch mode — only reruns affected tests on file change
  • Vitest UI integration for visual debugging
  • Works with Vitest coverage, snapshots, and all Vitest reporters
  • No separate Storybook server required
  • Unified test runner with your unit tests

Weaknesses:

  • Experimental — API will change (the package name says so)
  • Requires additional Vitest + browser configuration
  • Some Storybook addons may not work in Vitest context
  • MSW setup requires additional configuration

Best for: Teams using Vitest for unit tests who want unified test infrastructure, teams that prioritize fast watch mode during development.

Playwright Component Testing

Playwright has its own component testing mode (@playwright/experimental-ct-react) that mounts components in a real browser without Storybook.

// button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('button fires onClick', async ({ mount }) => {
  let clicked = false;
  const component = await mount(
    <Button onClick={() => { clicked = true; }}>Click me</Button>
  );

  await component.click();
  expect(clicked).toBe(true);
});

Strengths:

  • Does not require Storybook at all
  • Full Playwright API including network interception, device emulation, screenshots
  • Excellent for components that need full browser APIs (geolocation, clipboard, etc.)
  • Integrates with Playwright's visual comparison (toHaveScreenshot)

Weaknesses:

  • No story reuse — you write tests separately from stories
  • No design-to-test alignment — designers cannot see the test cases
  • No Storybook add-on ecosystem (a11y, msw, docs)
  • More configuration per test (no decorator composition)

Best for: Teams without Storybook, or components that need Playwright-specific capabilities not available in the Storybook test runner.

Making the Choice

Here is a decision framework:

Use test-runner when:

  • You have an existing Storybook with play functions
  • You want the simplest possible setup
  • Your CI already runs Storybook builds
  • You want MSW mocking to work without extra configuration

Use Vitest integration when:

  • You are already using Vitest for unit tests
  • Fast watch mode during development is a priority
  • You want unified coverage reports across unit and component tests
  • You are starting a new project and can tolerate the experimental API

Use Playwright component tests when:

  • You don't use Storybook
  • You need Playwright-specific browser APIs
  • You prefer tests that are independent of your documentation system

Running Story Tests in CI

Regardless of which approach you choose, here is the CI pattern for the Vitest integration:

# .github/workflows/component-tests.yml
name: Component Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Install Playwright for Vitest browser mode
        run: npx playwright install --with-deps chromium

      - name: Run component tests
        run: npx vitest run --project storybook

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: coverage/lcov.info

Coverage from Stories

One underappreciated benefit of running stories as Vitest tests: you get component coverage data. Vitest can instrument your component source files and report which lines were exercised by your stories:

// vitest.config.ts (add coverage configuration)
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      include: ['src/components/**/*.{ts,tsx}'],
      exclude: ['**/*.stories.{ts,tsx}', '**/*.test.{ts,tsx}'],
      reporter: ['text', 'lcov', 'html'],
    },
  },
});

Run with coverage:

npx vitest run --coverage --project storybook

This tells you which component code is exercised by your stories — a useful metric for understanding how comprehensive your story coverage actually is.

What the Ecosystem Looks Like Now

The testing landscape for Storybook components has matured significantly. You have multiple well-supported options, and the choice depends on your team's existing toolchain and priorities, not on capability gaps.

The most important shift is that stories are no longer just documentation. When you write a play function, you are writing an executable specification that runs in the browser UI, in test-runner, and now in Vitest. That write-once-run-everywhere quality is what makes the Storybook testing ecosystem compelling: your design documentation and your test suite are the same artifact.

For production coverage — monitoring that your component interactions work correctly in your deployed application with real users, real data, and real network conditions — HelpMeTest provides continuous end-to-end testing that complements the component-level testing you've built in Storybook, catching integration issues that isolated component tests cannot see.

Read more

Start now free