Collecting code coverage from Storybook stories

This page is part of the test-runner automation section of the Storybook isolation workflows guide, which covers what a story run can produce beyond a pass or fail.

The specific problem: you have three hundred stories and no idea which component states they miss. Unit-test coverage does not tell you, because it measures a different suite against different code paths.

Problem statement

Story coverage answers a question no other metric does: which of a component’s rendered states does the design system actually document? A component at 20% story coverage has most of its behaviour visible to nobody — not to a reviewer browsing Storybook, not to the visual regression baselines captured from those stories, and not to the test-runner.

The obstacle is that coverage requires instrumentation, and Storybook’s preview bundle is built by Vite or webpack without it. Running the test-runner with --coverage against an uninstrumented bundle produces an empty report and no error.

Root cause

Istanbul coverage works by rewriting source at build time to increment counters. The runner can only collect counters that exist, so the instrumentation has to be added to the Storybook build — not to the test command.

The second obstacle is scope. Instrumenting everything, including dependencies and the story files themselves, produces a number dominated by code nobody intended to measure. The include and exclude patterns matter as much as enabling the plugin.

How story coverage is produced Four stages left to right. The highlighted first stage instruments the preview bundle with vite-plugin-istanbul. build-storybook then produces an instrumented static bundle. test-storybook with the coverage flag collects counters per story. Finally nyc merges them into an lcov file and an HTML report. Instrumentation happens at build time, not test time vite-plugin-istanbul instruments the preview build-storybook instrumented bundle test-storybook --coverage collects per story nyc report lcov + html

Minimal reproduction

# no instrumentation in the bundle, so nothing is collected
npm run build-storybook
npx test-storybook --url http://127.0.0.1:6006 --coverage
# coverage/coverage-final.json contains an empty object

The command succeeds and the report is empty, which is easy to mistake for “no code is covered” rather than “no code was instrumented”.

Step-by-step fix

1. Instrument the preview bundle at build time

// .storybook/main.ts
import istanbul from 'vite-plugin-istanbul';

export default {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
  async viteFinal(config, { configType }) {
    if (configType === 'PRODUCTION') {
      config.plugins?.push(
        istanbul({
          include: 'src/components/**/*',      // the code you mean to measure
          exclude: ['node_modules', '**/*.stories.*', '**/*.test.*'],
          extension: ['.ts', '.tsx'],
          requireEnv: false,
        }),
      );
    }
    return config;
  },
};

What this does: adds counters to component source only. Excluding story and test files keeps the denominator to code that ships.

2. Collect during the run

npm run build-storybook -- --quiet
npx concurrently -k -s first \
  "http-server storybook-static -p 6006 --silent" \
  "wait-on tcp:6006 && test-storybook --url http://127.0.0.1:6006 --coverage"

What this does: the runner reads the coverage object the instrumented bundle exposes after each story and merges the counters into a JSON file.

3. Produce a readable report

npx nyc report \
  --temp-dir coverage/storybook \
  --reporter=text-summary --reporter=lcov --report-dir coverage/report

What this does: turns the raw counters into a console summary and an HTML report where clicking a file shows which branches no story reaches.

4. Read it as a gap analysis, not a score

The useful output is not the headline percentage but the list of components far below it. A component in the teens almost always has one story covering its default and none for its loading, empty or error states — which are exactly the states a reviewer never sees and a baseline never captures.

5. Merge with unit coverage only if you understand the sum

# combine both suites' counters before reporting
npx nyc merge coverage/unit coverage/merged/unit.json
npx nyc merge coverage/storybook coverage/merged/storybook.json
npx nyc report --temp-dir coverage/merged --reporter=lcov

What this does: produces one figure across both suites. It is genuinely useful for finding code neither suite touches, and genuinely misleading as a target, because the two suites are meant to cover different things.

Verification

What story coverage reveals A coverage summary produced from a story run. Statements are at 78.4 percent, branches at 61.2 percent, functions at 81 percent and lines at 78.9 percent. Two components stand out: Table at 41 percent, missing its dense and empty states, and Toast at 18 percent because it has no stories at all. nyc report --reporter=text-summary Statements : 78.4% ( 1893/2414 ) Branches : 61.2% ( 447/730 ) Functions : 81.0% ( 341/421 ) Lines : 78.9% ( 1841/2333 ) uncovered: Table.tsx 41% dense + empty states uncovered: Toast.tsx 18% no stories at all

The check that instrumentation is working is that the number is non-zero and moves. Add a story for a component’s error state, rerun, and its file’s percentage should rise. If nothing moves, the include pattern is not matching the source.

Edge cases and caveats

Four coverage sources and what each measures Four rows. Unit-test coverage answers which logic is exercised and is blind to what a user actually sees. Story coverage answers which component states are documented and is blind to logic with no visual state. Merging both reveals the real gap. End-to-end coverage answers which journeys run and is blind to everything off a journey. Coverage source Answers Blind to Unit tests which logic is exercised what a user sees Storybook stories which states are documented logic with no visual state Both, merged the real gap code nothing renders End-to-end which journeys run everything off a journey
  • Instrumenting only production builds. Leaving the plugin on in development slows hot reloading noticeably for no benefit. Gate it on the production build as above, or on an environment variable set only by the coverage job.

  • Source maps. Without them the report points at bundled output rather than source files, which makes it unreadable. Ensure the Storybook build emits source maps when instrumenting.

  • Coverage as a gate. A threshold on story coverage pushes people to write stories that raise the number rather than stories worth reviewing. Report it, track its direction, and gate on the tiers where coverage means something more specific.

FAQ

Is story coverage a substitute for unit-test coverage?

No — they measure different suites against different concerns. Unit coverage tells you which logic is exercised; story coverage tells you which rendered states are documented. A component can be fully covered by unit tests and barely covered by stories, which means its logic is tested and most of its appearance is undocumented and unbaselined. Both numbers are useful, and neither substitutes for the other.

Why is my branch coverage so much lower than statement coverage?

Because stories tend to cover the happy path of each component and skip its conditionals. Every condition for a loading, empty, error or permission-denied state is a branch that only a story rendering that state will reach. A large statement-to-branch gap is the most actionable signal the report gives you: it is a list of states nobody has documented.

Can I collect coverage from a sharded run?

Yes. Each shard writes its own counter files; publish them as artifacts, download all of them into one directory, and merge before reporting. Reporting per shard instead produces several partial figures, none of which describes the suite.

Does instrumentation change what the stories render?

It should not — the plugin adds counters around statements without altering behaviour — but it does change the bundle, so an instrumented build is a poor source for visual baselines. Keep the coverage build separate from the build your snapshot job captures, and you avoid ever having to reason about whether a diff came from the instrumentation.