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.
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
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
-
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.
Related
- Test-Runner Automation — the parent section on running stories as an unattended suite
- Component Variants — the variant matrix that determines how much of a component stories reach
- Test Scope Definition — reading coverage as a per-tier signal rather than one number
- Artifacts & Pipeline Reporting — publishing the coverage report the run produces