Storybook test-runner automation

This section sits under Storybook isolation workflows and covers turning the stories you already have into a test suite that runs unattended. Storybook’s test-runner takes the built static bundle, opens each story in a real Playwright browser, executes its play function, and reports the result — so an assertion written once for the browser becomes a gate in CI without a second test file.

Prerequisites

How the runner turns stories into tests

The test-runner is a Jest runner with a Playwright driver. It reads index.json from the built Storybook, generates one test per story, and for each one navigates a real browser page to that story’s iframe URL. A story with no play function still becomes a smoke test — it must render without throwing — and a story with one runs those assertions in the browser.

This is why it costs so little to adopt. The stories exist for review; the runner reinterprets them as a suite. And because the browser is real, assertions can depend on things jsdom cannot provide: layout, the CSS cascade, focus order, and the behaviour of native controls.

The five stages of a test-runner job Five stages left to right: build-storybook produces a static bundle; http-server serves it on port 6006; wait-on polls until it answers; the highlighted test-storybook stage executes play functions and the pre- and post-visit hooks; and the run emits JUnit and coverage artifacts. One build, one server, one runner build-storybook static bundle serve http-server :6006 wait-on poll until 200 test-storybook play + hooks junit + coverage artifacts

The two hooks are what make the runner more than a smoke test. preVisit runs before the story renders and is where you inject axe, seed storage, or set a viewport. postVisit runs after play has finished and is where an accessibility audit, a screenshot, or coverage collection belongs. Both receive the Playwright page, so anything Playwright can do is available.

The per-story lifecycle, and where you extend it Five bands describing what the test-runner does for each story. It navigates a real Playwright page to the story iframe. The preVisit hook, highlighted, is where axe is injected, storage is seeded and the viewport is set. The story renders and its play function runs. The postVisit hook, also highlighted, is where the accessibility audit, screenshots and coverage collection happen. Finally the result is reported per story. What the runner does per story Navigate to the story iframe a real Playwright page preVisit hook inject axe, seed storage, set viewport Render + run play() the story's own assertions postVisit hook axe audit, screenshot, coverage Report pass or fail, per story Everything you add to the run goes in one of the two hooks

Step-by-step implementation

Step 1 — Add the build, serve and wait scripts

The runner needs a Storybook it can reach over HTTP. Building once and serving statically is faster and far more deterministic than running the dev server.

{
  "scripts": {
    "build-storybook": "storybook build -o storybook-static",
    "serve-storybook": "http-server storybook-static --port 6006 --silent",
    "test-storybook": "test-storybook --url http://127.0.0.1:6006",
    "test-storybook:ci": "concurrently -k -s first -n SB,TEST \"npm run serve-storybook\" \"wait-on tcp:6006 && npm run test-storybook\""
  }
}

Verify it works: npm run build-storybook && npm run test-storybook:ci should report one test per story. A run that reports zero tests found means the runner is pointed at a URL that is not serving index.json.

Step 2 — Create the runner config

// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';

const config: TestRunnerConfig = {
  async preVisit(page) {
    await page.setViewportSize({ width: 1280, height: 720 });
  },
  async postVisit(page, context) {
    // fail the story if it logged an error while rendering
    const errors = await page.evaluate(() => (window as any).__errors ?? []);
    if (errors.length) throw new Error(`${context.id} logged ${errors.length} error(s)`);
  },
  tags: { exclude: ['no-test'] },
};

export default config;

Verify it works: tag a story ['no-test'] and confirm the run reports one fewer test.

Step 3 — Add an accessibility audit in postVisit

import { injectAxe, checkA11y } from 'axe-playwright';

const config: TestRunnerConfig = {
  async preVisit(page) {
    await injectAxe(page);
  },
  async postVisit(page) {
    await checkA11y(page, '#storybook-root', {
      detailedReport: true,
      axeOptions: { runOnly: ['wcag2a', 'wcag2aa'] },
    });
  },
};

Verify it works: remove an aria-label from an icon-only button and the run should fail on that story with the button-name rule named.

Step 4 — Emit a JUnit report for the CI summary

# JEST_JUNIT_OUTPUT_FILE is read by jest-junit, which the runner supports directly
JEST_JUNIT_OUTPUT_FILE=reports/storybook.xml \
  npm run test-storybook -- --reporters=default --reporters=jest-junit

Verify it works: reports/storybook.xml should list one testcase per story, which most CI platforms will render as an inline test summary.

Step 5 — Shard the run once it outgrows one runner

# each CI runner takes one slice; results merge in the reporting step
npm run test-storybook -- --shard=2/4 --maxWorkers=2

Verify it works: the four shards’ story counts should sum to the total the unsharded run reported.

Configuration reference

The options that shape a test-runner run Seven rows. The url flag names the served Storybook. maxWorkers sets how many browser contexts run in parallel. The shard flag splits stories across runners. The coverage flag collects istanbul coverage. In test-runner.ts, preVisit runs before a story renders, postVisit runs after its play function finishes, and a tag exclusion list skips stories by tag. Option Where Effect --url CLI the served Storybook to test --maxWorkers CLI parallel browser contexts --shard CLI split stories across runners --coverage CLI collect istanbul coverage preVisit test-runner.ts set up before the story renders postVisit test-runner.ts assert after play() finishes tags.exclude test-runner.ts skip stories by tag
Setting Type Default Effect
--url string http://localhost:6006 Storybook instance under test
--maxWorkers number CPU count Parallel browser contexts
--shard n/total none Slice of stories this runner takes
--coverage flag off Collects istanbul coverage from the preview
--failOnConsole flag off Turns a console error into a failure
tags.exclude string[] [] Story tags skipped by the run

Step 6 — Seed browser state in preVisit

Stories that read cookies, storage or a media preference need that state set before the story renders, not after. preVisit is the only hook that runs early enough.

async preVisit(page) {
  await page.addInitScript(() => {
    localStorage.setItem('cookie-consent', 'accepted');
    localStorage.setItem('theme', 'light');
  });
  await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'light' });
}

Verify it works: a story that renders a consent banner when the flag is absent should now render without it. Removing the addInitScript call should bring the banner back, which confirms the seeding is what changed the render rather than something incidental.

Note that addInitScript runs before any page script, which is what makes it different from setting storage after navigation — a component reading storage during its first render would otherwise miss the value entirely.

Step 7 — Gate the job in CI

      - name: Storybook tests
        run: npm run test-storybook:ci
        env:
          JEST_JUNIT_OUTPUT_FILE: reports/storybook.xml

      - name: Upload reports
        if: always()                      # publish the report even when the step failed
        uses: actions/upload-artifact@v4
        with:
          name: storybook-test-report
          path: reports/

Verify it works: break one story’s assertion deliberately and push. The job should go red, the report artifact should still be attached, and the failing story id in the report should match the one in the log.

Common pitfalls

Running against the dev server. storybook dev rebuilds on change and serves an unoptimised bundle, so a run can race a rebuild and stories can behave differently from the build you ship. Always test the static output.

No wait step. Starting the runner before the server is listening produces a confusing “0 tests found” rather than a connection error. wait-on tcp:6006 costs nothing and removes the whole class.

Default worker count in CI. The runner defaults to the machine’s CPU count, and a two-core runner starting eight browser contexts will thrash or run out of memory. Set --maxWorkers=2 explicitly on constrained runners.

Assertions in postVisit that assume a play function ran. postVisit runs for every story, including those with no play. Guard on context.hasPlayFunction when an assertion only makes sense after an interaction.

Making failures diagnosable

A test-runner failure arrives as a story id and a Jest assertion message, which is enough to know that something broke and rarely enough to know why. Three additions turn a red run into something a developer can act on without reproducing it locally.

Attach a screenshot on failure. The postVisit hook receives the Playwright page, so capturing the story at the moment it failed is three lines. Written into the job’s artifact directory, it means the first question — “what did it actually look like?” — is answered before anyone opens an editor.

async postVisit(page, context) {
  try {
    await checkA11y(page, '#storybook-root');
  } catch (err) {
    await page.screenshot({ path: `reports/failures/${context.id}.png` });
    throw err;                      // rethrow so the story still fails
  }
}

Capture the console. A story that renders “successfully” while logging a React key warning or a failed request is usually the story that fails intermittently later. Collecting console output in preVisit and attaching it to the failure message puts the cause and the symptom in the same place.

Name the story, not the file. The runner’s default test name is the story id, which is stable and searchable — resist renaming it to something prettier, because that id is what appears in the Storybook URL, the Chromatic diff list, and the coverage report. Being able to paste one string into three tools is worth more than a tidier CI log.

The general principle is that the runner sits in CI, where nobody is watching. Everything a developer would have looked at if they had been present has to be captured at the moment of failure, because by the time anyone reads the log the browser is gone.

Keeping the run fast enough to gate on

A gate that takes fifteen minutes stops being a gate — developers start merging around it, and the check becomes decorative. Three levers keep a story suite inside the window a pull request can tolerate.

Reuse the build. Building Storybook and running the tests in the same job means every retry rebuilds. Publishing the static bundle as an artifact from a build job and downloading it in the test job means a retry costs only the test time, and a sharded run builds once instead of once per shard.

Exclude what does not need testing. Documentation-only stories, deliberately broken examples, and stories that exist to demonstrate an error state to a human are all reasonable to skip. A no-test tag excluded in the config makes that explicit and visible in the story source, rather than hidden in a CI filter nobody reads.

Shard on story count, not file count. The runner generates one test per story, so a single file with forty variant stories is forty units of work. Sharding distributes those units, which is why a variant matrix that would dominate a single runner spreads evenly across four.

Measure after each change rather than assuming. The number that matters is wall-clock from pull-request open to the check reporting, and it includes the build, the artifact transfer and the slowest shard — not the figure the runner prints at the end.

Integration point

The runner is the execution layer under several other sections. It is how interaction testing assertions become a gate rather than something a human clicks through, and how accessibility testing stops being advisory. Its output feeds the same pipeline gating thresholds as every other check, and its JUnit and coverage files are collected by the artifacts and pipeline reporting section. Where the concern is pixels rather than behaviour, the same static build is what visual regression tooling captures.

FAQ

Does the test-runner replace Playwright component tests?

For component-level assertions, largely yes — and it costs less, because the stories already exist and are already reviewed. Playwright component testing remains useful when you need to mount a component with props a story does not express, or drive several components together on one page. Running both against the same components mostly duplicates coverage.

Why does a story pass in the browser but fail in the runner?

Almost always because the manager and the static build are not identical. A decorator registered only in manager.ts, an addon that is not part of the build, or an environment variable present locally and absent in CI will all produce that gap. Build the static bundle locally, serve it, and open the failing story there — the difference will be visible immediately.

How long should a test-runner job take?

Roughly a second per story on a warm runner, so a 300-story Storybook lands around five minutes unsharded. Beyond that, shard it: four runners bring the same suite to a little over a minute, which keeps it inside the window a pull-request gate can occupy without developers routing around it.

Can the runner collect code coverage?

Yes, with --coverage, provided the preview bundle is instrumented — usually with vite-plugin-istanbul scoped to the Storybook build. The figure it produces answers a different question from unit-test coverage: it measures which component code paths your stories exercise, which is a useful gap analysis for a design system.

What should happen when a story has no play function?

It still runs, as a smoke test: the runner navigates to it and fails if rendering throws or the page reports an uncaught error. That is genuinely valuable — a component that crashes on a particular prop combination is caught by the story existing at all, with no assertion written. Treat it as a floor rather than a goal, and add play functions where behaviour matters.

Can the runner test stories from a deployed Storybook?

Yes — point --url at any reachable Storybook, which is a convenient way to smoke-test a preview deployment. It is the wrong choice for a pull-request gate, though, because the deployment may lag the branch under review, so a green run can describe code that is no longer there. Gate on a build produced from the commit being reviewed.

How do I stop one flaky story failing the whole run?

Fix it rather than tolerate it — a flaky story is a non-deterministic story, and everything downstream of it, including visual baselines, inherits that non-determinism. If it must ship while the fix is pending, tag it and exclude the tag, so the exclusion is visible in the story source and shows up in code review rather than being buried in a retry setting.