How Portable Stories Work with Vitest, Jest, and Playwright: A Complete Guide

Portable stories allow you to reuse Storybook stories in Vitest, Jest, and Playwright Component Tests by composing them with composeStory or composeStories, enabling a single source of truth for UI testing across your JavaScript toolchain.

Portable stories are a core feature of the Storybook ecosystem that let you run stories outside the Storybook UI. According to the storybookjs/storybook source code, this functionality works by composing Component Story Format (CSF) exports with global project annotations, making them executable in any JavaScript test environment.

Core Architecture of Portable Stories

The portable stories system normalizes Storybook's rendering lifecycle so it can execute in Node.js-based test runners (Vitest, Jest) or browser-based component testing (Playwright CT). The architecture relies on four primary functions that handle configuration loading, story composition, and test fixture creation.

Key Functions and Their Roles

setProjectAnnotations loads your global preview configuration (.storybook/preview.*) once per test suite. This ensures decorators, globals, and loaders defined in Storybook apply to every composed story. The implementation lives in code/core/src/preview-api/modules/store/csf/portable-stories.ts at lines 73-86.

composeStory transforms a single CSF story export and its meta into a prepared story object. This function normalizes args, applies decorators, and returns a callable component along with helper methods. You can find the source at lines 90-150 in the same portable-stories.ts file.

composeStories provides bulk composition for all stories in a CSF module. It iterates over named exports, filters out non-story exports, and returns an object containing composed versions of every story. This is implemented at lines 174-203 in portable-stories.ts.

createTest (Playwright only) generates a custom test fixture that replaces Playwright's standard test export. This fixture provides a mount method that orchestrates the story lifecycle: load() → mount() → play(). The implementation spans lines 19-73 in portable-stories.ts.

Using Portable Stories in Vitest

Vitest integration follows a straightforward setup pattern where you initialize global annotations once, then compose stories in individual test files.

Initial Setup

Create a setup file that runs before your tests:

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

setProjectAnnotations(projectAnnotations);

Configure Vitest to use this setup file in your vitest.config.ts:

export default {
  test: {
    environment: 'jsdom',
    setupFiles: ['./test/setup.ts'],
  },
};

Writing Tests with composeStories

Import your stories and compose them for testing:

import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import * as ButtonStories from '../src/Button.stories';

const { Primary, Secondary } = composeStories(ButtonStories);

test('Primary renders with default props', () => {
  render(<Primary />);
  expect(screen.getByText('Primary Button')).toBeInTheDocument();
});

test('Secondary accepts overridden args', () => {
  render(<Secondary label="Custom Label" />);
  expect(screen.getByText('Custom Label')).toBeInTheDocument();
});

Running Interaction Tests

If your stories define a play function, invoke it to test interactions:

test('Primary handles click interactions', async () => {
  const { container } = render(<Primary />);
  
  // Run the play function to execute interactions
  await Primary.play?.({ canvasElement: container });
  
  // Assert interaction results
  expect(screen.getByRole('button')).toHaveAttribute('data-clicked', 'true');
});

Reference the Vitest documentation at docs/api/portable-stories/portable-stories-vitest.mdx for additional configuration options.

Using Portable Stories in Jest

Jest integration mirrors the Vitest pattern exactly, differing only in configuration syntax.

Configuration Setup

Create a setup file identical to the Vitest example:

// jest.setup.js
import { setProjectAnnotations } from '@storybook/react';
import projectAnnotations from './.storybook/preview';

setProjectAnnotations(projectAnnotations);

Reference this in your Jest configuration:

// jest.config.js
module.exports = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jsdom',
};

Test Implementation

The test syntax remains identical to Vitest:

import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import * as CardStories from './Card.stories';

const { Small, Large } = composeStories(CardStories);

test('Small card renders title correctly', () => {
  const { getByText } = render(<Small />);
  expect(getByText('Small Card')).toBeTruthy();
});

test('Large card executes play interactions', async () => {
  const { container } = render(<Large />);
  await Large.play?.({ canvasElement: container });
  expect(container.querySelector('.expanded')).toBeInTheDocument();
});

Consult docs/api/portable-stories/portable-stories-jest.mdx for framework-specific nuances.

Using Portable Stories in Playwright Component Tests

Playwright Component Testing (CT) requires a different approach because tests run in a real browser context rather than Node.js. The integration uses createTest to bridge Storybook's story lifecycle with Playwright's mount fixture.

Creating the Test Fixture

First, generate a customized test instance that understands portable stories:

// tests/playwright-test.ts
import { test as base } from '@playwright/experimental-ct-react';
import { createTest } from '@storybook/react';

export const test = createTest(base);
export { expect } from '@playwright/experimental-ct-react';

The createTest function (defined in code/core/src/preview-api/modules/store/csf/portable-stories.ts lines 19-73) wraps Playwright's base test and injects a custom mount fixture.

Composing Stories for the Browser

Create a separate file that composes stories for browser execution:

// src/Button.stories.portable.ts
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';

export default composeStories(stories);

This file runs in the browser context during Playwright CT execution, whereas the test file runs in Node.js.

Writing Component Tests

Import both the test fixture and composed stories to write tests:

import { test, expect } from './playwright-test';
import * as stories from './Button.stories.portable';

test('Primary button renders and handles interactions', async ({ mount }) => {
  // mount calls story.load() → story.mount() → story.play() automatically
  const component = await mount(stories.Primary);
  
  await expect(component).toContainText('Primary');
  
  // Additional Playwright interactions
  await component.click();
  await expect(component).toHaveAttribute('data-active', 'true');
});

The custom mount fixture orchestrates the full story lifecycle: it executes story.load() to run loaders and beforeEach hooks, then story.mount() to render the component, and finally story.play() to execute interaction tests.

Reference the Playwright documentation at docs/api/portable-stories/portable-stories-playwright.mdx for advanced configuration options.

Key Source Files in storybookjs/storybook

Understanding the implementation details helps when debugging or extending portable stories functionality:

File Role Location
portable-stories.ts Core engine containing setProjectAnnotations, composeStory, composeStories, and createTest code/core/src/preview-api/modules/store/csf/portable-stories.ts
portable-stories.tsx React-specific wrapper adding renderer entry-preview configurations code/renderers/react/src/portable-stories.tsx
portable-stories-vitest.mdx Official Vitest integration documentation docs/api/portable-stories/portable-stories-vitest.mdx
portable-stories-jest.mdx Official Jest integration documentation docs/api/portable-stories/portable-stories-jest.mdx
portable-stories-playwright.mdx Official Playwright CT integration documentation docs/api/portable-stories/portable-stories-playwright.mdx

Summary

Portable stories unify your UI testing strategy by allowing Storybook stories to run in any JavaScript testing environment. The key takeaways include:

  • setProjectAnnotations initializes global Storybook configuration (decorators, loaders) once per test suite.
  • composeStory and composeStories transform CSF exports into executable components with play, load, and run methods.
  • Vitest and Jest use identical patterns: import composed stories and render them using your framework's testing utilities.
  • Playwright Component Tests require createTest to generate a custom fixture that orchestrates the story lifecycle (load → mount → play) in a real browser context.
  • All implementations share the same core logic in code/core/src/preview-api/modules/store/csf/portable-stories.ts, ensuring consistent behavior across environments.

Frequently Asked Questions

What is the difference between composeStory and composeStories?

composeStory processes a single story export and its associated meta, returning a composed function that renders that specific story. composeStories processes an entire CSF module, automatically composing all named story exports into an object where keys match the original export names. Use composeStory when you need to test individual stories with custom configurations, and composeStories when you want to iterate over all stories in a file or import them in bulk.

How do portable stories handle decorators and global configuration?

Portable stories respect your .storybook/preview.* configuration through the setProjectAnnotations function. When called in your test setup file, it merges global decorators, loaders, globals, and parameters into a global annotations object. Both composeStory and composeStories automatically apply these annotations to every composed story, ensuring that stories rendered in Vitest, Jest, or Playwright behave identically to those in the Storybook UI.

Can I use the play function in all three testing environments?

Yes, the play function works across Vitest, Jest, and Playwright, though invocation differs slightly. In Vitest and Jest, you manually call await story.play?.({ canvasElement: container }) after rendering the component. In Playwright Component Tests, the custom mount fixture automatically executes the play function after mounting. The play function receives a context object containing canvasElement, args, and other Storybook-specific properties, allowing you to reuse interaction tests written for Storybook in any testing environment.

Why does Playwright require createTest while Vitest and Jest do not?

Playwright Component Tests run in a real browser environment with a dual-process architecture: test code runs in Node.js while components render in a browser context. The createTest function generates a custom Playwright fixture that bridges this gap. It replaces the standard mount with one that serializes the composed story to the browser, executes story.load() to run loaders, renders the component via story.mount(), and then runs story.play(). Vitest and Jest run entirely in Node.js (typically with JSDOM or Happy DOM), allowing direct import and rendering of composed stories without special fixture wrappers.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →