Reusing Stories in Vitest with composeStories
Teams that use Storybook often end up describing each component twice: once as stories with carefully chosen args, decorators and mocked data, and again as test fixtures that recreate the same states by hand. The two drift apart. A story shows the “empty basket” state one way while the test renders it another, and when the component changes, only one of them gets updated. composeStories removes the duplication by turning each story into a renderable component that carries its args, decorators, loaders and global project configuration, so a Vitest test can render the exact state a designer reviewed in Storybook and assert on it. This guide covers setting up project annotations once, rendering composed stories, overriding args per test, running play functions inside Vitest, and the configuration mistakes that make composed stories render differently from Storybook itself. It belongs to Storybook interaction tests.
Root Cause Analysis
The duplication problem starts innocently. A component gets a story for each meaningful state, and a test file that renders the component with similar props. Six months later the story uses a new required prop and a theme decorator, and the test still passes because it provides its own props and no theme — while the real component, in the real theme, has a contrast bug no test sees. The fixture and the story disagree about what the component’s states are.
The second cause is missing global configuration. Stories rarely render on their own; they depend on decorators and parameters defined in .storybook/preview.ts — providers, themes, router wrappers, MSW handlers. A composed story rendered without those annotations loses them, and the test either crashes with a missing-provider error or, worse, renders a subtly different component. Setting project annotations once in the Vitest setup file fixes this for every test.
The third is assuming play functions run automatically. A composed story is a component; rendering it does not execute its interaction script. Tests that expect the post-interaction state without calling play assert on the initial render and pass or fail for the wrong reasons.
Reproducible Setup
A basket summary component with stories for three states, a theme decorator and an MSW loader configured globally in Storybook.
// src/basket/BasketSummary.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { BasketSummary } from './BasketSummary';
const meta = { component: BasketSummary, args: { currency: 'GBP' } } satisfies Meta<typeof BasketSummary>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = { args: { lines: [] } };
export const WithItems: Story = { args: { lines: [{ sku: 'MUG', name: 'Mug', qty: 2, price: 12 }] } };
export const RemovingLastItem: Story = {
args: { lines: [{ sku: 'MUG', name: 'Mug', qty: 1, price: 12 }] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Remove Mug' }));
await expect(canvas.getByText('Your basket is empty')).toBeInTheDocument();
},
};
Project annotations are applied once in the Vitest setup file, so every composed story gets the same global decorators, parameters and loaders as in Storybook.
// vitest.setup.ts
import { setProjectAnnotations } from '@storybook/react';
import * as previewAnnotations from './.storybook/preview';
const annotations = setProjectAnnotations([previewAnnotations]);
beforeAll(annotations.beforeAll);
Implementation
Step 1 — Compose all stories from a file. composeStories returns one component per named export.
// src/basket/BasketSummary.test.tsx
import { composeStories } from '@storybook/react';
import * as stories from './BasketSummary.stories';
const { Empty, WithItems, RemovingLastItem } = composeStories(stories);
test('the empty state explains how to add items', () => {
render(<Empty />);
expect(screen.getByText('Your basket is empty')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Browse products' })).toBeInTheDocument();
});
Step 2 — Assert on the state the story represents. The story provides the setup; the test adds precise assertions that would clutter the story.
test('shows the line total and basket total', () => {
render(<WithItems />);
expect(screen.getByRole('row', { name: /Mug/ })).toHaveTextContent('£24.00');
expect(screen.getByTestId('basket-total')).toHaveTextContent('£24.00');
});
Step 3 — Override args for variations the story does not cover. Passing props to a composed story merges them over the story’s args, leaving decorators and globals intact.
test('formats totals in the configured currency', () => {
render(<WithItems currency="EUR" />);
expect(screen.getByTestId('basket-total')).toHaveTextContent('€24.00');
});
Step 4 — Run the play function explicitly. run() renders the story with its loaders and then executes play, including the story’s own expect calls.
test('removing the last item shows the empty state', async () => {
await RemovingLastItem.run();
// The play function asserted the empty state; add assertions it does not cover.
expect(screen.queryByTestId('basket-total')).not.toBeInTheDocument();
});
Step 5 — Smoke-test every story in a file. A loop renders each story and fails if any throws, giving cheap coverage for stories without dedicated tests.
test.each(Object.entries(composeStories(stories)))('%s renders without errors', async (_name, Story) => {
await Story.run();
});
Step 6 — Let Storybook’s MSW handlers apply. If the preview file registers handlers through a loader or parameters, composed stories use them too, so data-fetching components receive the same responses as in Storybook. Reset handlers after each test as usual.
Step 7 — Keep story files test-friendly. Composed stories only help if the stories themselves are deterministic. Avoid random data, Date.now() and live network calls inside stories; seed any generated content, pin dates through a decorator or a global parameter, and route requests through handlers defined next to the story. The same discipline makes the stories better as documentation, because a reviewer sees the same state every time they open them, and it makes browser-based story tests and visual snapshots stable as well. A useful rule of thumb: if a story could not be snapshotted reliably, it cannot be tested reliably either, and the fix belongs in the story rather than in the test.
It also pays to name stories after the state they represent rather than the props they set. EmptyBasket, SingleItem and OutOfStockLine read well in test output and in the Storybook sidebar; Default, Variant2 and WithLongProps tell nobody what the component is supposed to do. Because composed story names become test names in the smoke loop, good names turn the test report into a readable list of the component’s supported states.
Verification
Remove the setProjectAnnotations call from the setup file and rerun. Tests that depend on the theme decorator or global providers must fail — confirming they were using the real preview configuration. Then change the WithItems story’s price to 13 and confirm the total assertion fails, proving the test follows the story rather than a private fixture.
npx vitest run src/basket/BasketSummary.test.tsx --reporter=verbose
# ✓ the empty state explains how to add items
# ✓ shows the line total and basket total
# ✓ formats totals in the configured currency
# ✓ removing the last item shows the empty state
# ✓ Empty renders without errors
# ✓ WithItems renders without errors
# ✓ RemovingLastItem renders without errors
Troubleshooting
Symptom: “useTheme must be used within ThemeProvider” when rendering a composed story. Diagnosis: project annotations were not applied, so preview decorators are missing. Fix: call setProjectAnnotations with the preview module in the Vitest setup file.
Symptom: the play function’s assertions never run. Diagnosis: the test used render(<Story />) instead of await Story.run(). Fix: use run() when the interaction is part of what is being tested.
Symptom: loaders’ data is undefined. Diagnosis: loaders are asynchronous and only execute through run() or load(). Fix: call await Story.load() before render, or use run().
Symptom: styles or fonts differ from Storybook. Diagnosis: jsdom applies no layout or real CSS. Fix: assert on content and roles in Vitest, and leave visual checks to browser-based story tests or visual regression testing.
FAQ
Should stories contain assertions at all?
Light ones in play functions, yes — they document the expected interaction and run in Storybook too. Keep detailed or edge-case assertions in Vitest tests, where they do not clutter the story reviewers read.
Does composeStories work with Vue or Svelte?
Yes; each renderer package exports its own composeStories and setProjectAnnotations. The pattern is identical, rendering through the framework’s Testing Library.
What if a state is only needed by tests?
Pass args to an existing story, or add a story — if the state matters enough to test, it probably deserves to be visible in Storybook as well.
How do these relate to the Storybook test runner?
The test runner executes stories in a real browser; see running Storybook tests in CI with the test runner. composeStories brings the same stories into the Vitest suite.
Will composing stories slow the Vitest suite down?
Only slightly. Composing is cheap; the cost is the decorators and loaders the stories already need. If the preview file wraps every story in heavy providers, make those decorators conditional on parameters so simple components render without them, which speeds up Storybook as well.
Can I use composed stories in Playwright component tests?
Yes — Playwright’s experimental component testing can mount composed stories, which lets the same states run in a real browser. It is an option when a component needs layout or real CSS but you want to avoid a second set of fixtures.
Related
- Back to Storybook Interaction Tests
- Writing play functions for Storybook interaction tests — the scripts
run()executes. - Writing custom render helpers with providers — the alternative when a component has no stories.
- Catching CSS regressions in Storybook — visual checks on the same stories.