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
# From repo root — runs all package tests including Storybook'spnpm test
# Storybook-only, watch mode (interactive)pnpm --filter @na/storybook test
# Storybook-only, one-shot (CI mode)pnpm --filter @na/storybook test:ci
# Coverage reportpnpm --filter @na/storybook test:coverageThe 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:
- Smoke render — every story renders without crashing.
- Interaction (
play) — when a story exportsplay({ args, canvasElement, ... }), the addon executes it and asserts. - A11y — the addon-a11y panel runs
axe-coreagainst 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-storyexport 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.
- run: pnpm install --frozen-lockfile- run: pnpm exec playwright install --with-deps chromium- run: pnpm testConfiguration 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
vitestdirectly in stories. Usestorybook/test(re-exportsexpect,fn,userEvent,withinso play functions stay portable). - Don’t name a story
Error— it shadows the globalErrorconstructor and explodes when the same file usesnew Error(...). UseErrorState. - Don’t add a
playfunction that mirrors a unit test. The unit test is faster and runs without a browser.