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

> Discover how portable stories streamline UI testing with Vitest, Jest, and Playwright. Learn to reuse Storybook stories across your toolchain for a single source of truth.

- Repository: [Storybook/storybook](https://github.com/storybookjs/storybook)
- Tags: how-to-guide
- Published: 2026-02-27

---

**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`](https://github.com/storybookjs/storybook/blob/main/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:

```typescript
// 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`](https://github.com/storybookjs/storybook/blob/main/vitest.config.ts):

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

```

### Writing Tests with composeStories

Import your stories and compose them for testing:

```typescript
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:

```typescript
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:

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

setProjectAnnotations(projectAnnotations);

```

Reference this in your Jest configuration:

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

```

### Test Implementation

The test syntax remains identical to Vitest:

```typescript
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:

```typescript
// 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`](https://github.com/storybookjs/storybook/blob/main/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:

```typescript
// 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:

```typescript
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`](https://github.com/storybookjs/storybook/blob/main/portable-stories.ts) | Core engine containing `setProjectAnnotations`, `composeStory`, `composeStories`, and `createTest` | [`code/core/src/preview-api/modules/store/csf/portable-stories.ts`](https://github.com/storybookjs/storybook/blob/main/code/core/src/preview-api/modules/store/csf/portable-stories.ts) |
| [`portable-stories.tsx`](https://github.com/storybookjs/storybook/blob/main/portable-stories.tsx) | React-specific wrapper adding renderer entry-preview configurations | [`code/renderers/react/src/portable-stories.tsx`](https://github.com/storybookjs/storybook/blob/main/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`](https://github.com/storybookjs/storybook/blob/main/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.