Skip to content

Storybook testing

apps/storybook runs every story as a test in a real browser via Vitest’s browser mode + Playwright. Stories that export a play function become interaction tests; stories without one are smoke-rendered. The addon-a11y panel runs against every story.

Quick start

Terminal window
# From repo root — runs all package tests including Storybook's
pnpm test
# Storybook-only, watch mode (interactive)
pnpm --filter @na/storybook test
# Storybook-only, one-shot (CI mode)
pnpm --filter @na/storybook test:ci
# Coverage report
pnpm --filter @na/storybook test:coverage

The first run downloads Chromium via Playwright (~92 MB). Subsequent runs hit the system cache.

What gets tested

Every .stories.tsx in apps/storybook/src/stories/ becomes one or more test entries:

  1. Smoke render — every story renders without crashing.
  2. Interaction (play) — when a story exports play({ args, canvasElement, ... }), the addon executes it and asserts.
  3. A11y — the addon-a11y panel runs axe-core against the rendered story. Per .storybook/preview.ts, the test mode is 'todo' (violations surface in the panel without failing the build). Flip to 'error' per-story or globally once the catalog is audit-clean.

Writing an interaction test

Add play to a story. Use userEvent + expect from storybook/test.

import { expect, fn, userEvent, within } from 'storybook/test';
export const ClickFires: Story = {
args: {
children: 'Save',
onClick: fn(), // mock fn so we can assert calls
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Save' }));
await expect(args.onClick).toHaveBeenCalledOnce();
},
};

The same play function runs:

  • Inside the manager (the Interactions panel re-runs steps as you scrub).
  • In pnpm test (vitest browser mode).

Skipping a story from the test runner

Some stories misbehave in browser-mode Vitest (timers that fire after teardown, third-party libraries that mount portals into document.body, etc.). Tag the story file or single story to opt out:

const meta = {
title: 'Feedback/MutationStatus',
component: MutationStatus,
tags: ['autodocs', '!test'], // file-level skip
} satisfies Meta<typeof MutationStatus>;
// Or per-story
export const Pending: Story = {
args: { … },
tags: ['!test'],
};

When you skip, leave a comment explaining why and pointing at the corresponding @na/ui Vitest test that covers the behavior. We do not want stories silently un-tested.

What does NOT belong in a story play function

  • Anything you’d write a unit test for. Component logic with no rendering belongs in packages/ui/src/components/<X>.test.tsx.
  • Visual regression checks. We don’t ship a visual-test runner — Chromatic / Percy / Lost-pixel are out of scope until a maintainer wants the bill.
  • Snapshot tests. Storybook’s manager already shows the rendered story; snapshots add noise.

CI

The repo’s pnpm test (root) calls turbo run test, which runs every workspace’s test script. apps/storybook’s test:ci mode is non-interactive and exits with a non-zero code on failure. Drop it in the same CI step as the other workspace tests.

.github/workflows/ci.yml
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test

Configuration files

  • .storybook/main.ts — addons (addon-docs, addon-a11y, addon-vitest) + Vite dedupe for React.
  • .storybook/preview.ts — global decorators / parameters; sets the a11y test mode.
  • vitest.config.ts — Vitest browser-mode config, plugged into the Storybook test plugin.

Anti-patterns

  • Don’t import vitest directly in stories. Use storybook/test (re-exports expect, fn, userEvent, within so play functions stay portable).
  • Don’t name a story Error — it shadows the global Error constructor and explodes when the same file uses new Error(...). Use ErrorState.
  • Don’t add a play function that mirrors a unit test. The unit test is faster and runs without a browser.