Excluding Generated Code From Coverage Reports
A coverage percentage is a fraction, and most arguments about it are really arguments about the denominator. A repository that generates a forty-thousand-line API client from an OpenAPI schema will report a coverage figure dominated by code nobody wrote and nobody should test; the same repository with that client excluded reports something meaningful about the code its engineers actually maintain. This guide covers what genuinely belongs outside the denominator, how to express the exclusions so they stay reviewable, and — the important part — how to prove that nothing real slipped out with them. It sits under defining coverage thresholds.
Root Cause Analysis
Generated code is untestable in a specific sense: its correctness is a property of the generator and the schema, not of this repository. Writing tests for a generated client verifies that the generator works, which its own maintainers already do, and those tests must be regenerated whenever the schema changes, so they impose ongoing cost for no signal. The same argument applies to database migrations, which are verified by running them, and to bootstrap files whose only behaviour is wiring.
The reason this becomes a problem rather than a detail is scale. Generated output is often larger than the hand-written code around it, so it does not merely shift the figure — it dominates it. A repository can lose ten points of coverage overnight because someone regenerated a client with more endpoints, which makes the metric respond to events that have nothing to do with test quality.
The opposing risk is real too, and it is why exclusion deserves care rather than enthusiasm. An exclusion pattern written broadly — **/api/**, say — removes hand-written code alongside the generated kind, and because the figure goes up, nobody investigates. Exclusions are the one coverage setting that fails in the flattering direction.
Reproducible Setup
Start by finding out how much of the measured code is generated, which is usually more than people expect.
# lines by directory, largest first
find src -name '*.ts' -o -name '*.tsx' | xargs wc -l | sort -rn | head -12
# 41022 total under src/generated
# 3180 src/domain
# 2410 src/components
// vitest.config.ts — the starting point, measuring everything
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary'],
include: ['src/**/*.{ts,tsx}'],
},
},
});
Implementation
Step 1 — Exclude by provenance, never by how well-tested something is. The test for whether something belongs outside the denominator is whether a human wrote it and maintains it — not whether covering it would be inconvenient.
coverage: {
exclude: [
'src/generated/**', // produced by openapi-typescript
'src/**/*.gen.ts', // produced by the query codegen
'prisma/migrations/**', // verified by running them
'src/**/*.stories.tsx', // rendered by Storybook, not by tests
'src/main.tsx', // bootstrap wiring
'src/**/*.d.ts', // types have no runtime
],
},
Step 2 — Write a comment beside every exclusion. The list is read by whoever next wonders why the number is what it is, and an unexplained glob is indistinguishable from someone hiding a problem.
Step 3 — Prefer marking the generator’s output than listing paths. A generated file can carry a header, and matching on that is far more robust than a path that changes when the output directory moves.
// scripts/list-generated.ts — paths whose files declare themselves generated
import { globSync } from 'glob';
import { readFileSync, writeFileSync } from 'node:fs';
const marked = globSync('src/**/*.{ts,tsx}').filter((f) =>
readFileSync(f, 'utf8').slice(0, 400).includes('@generated'),
);
writeFileSync('coverage-exclude.json', JSON.stringify(marked.sort(), null, 2));
console.log(`${marked.length} generated file(s)`);
// vitest.config.ts — consume the generated list
import excluded from './coverage-exclude.json';
export default defineConfig({
test: { coverage: { exclude: [...excluded, 'src/**/*.d.ts'] } },
});
Step 4 — Keep migrations and bootstrap out, but wiring code in. The distinction matters: a file that only imports and registers things has no behaviour to test, while a file that decides which things to register does, and excluding it hides a real decision.
// src/main.tsx — pure wiring, reasonable to exclude
createRoot(document.getElementById('root')!).render(<App />);
// src/config/features.ts — a decision, must stay measured
export const enabledFeatures = (env: Env) =>
env.TIER === 'enterprise' ? ALL_FEATURES : BASIC_FEATURES;
Step 5 — Review the exclusion list in code review like any other policy. A change to this list changes every coverage number in the repository, so it deserves the same scrutiny as a change to the thresholds themselves.
Step 6 — Report the excluded line count alongside the percentage. A figure of eighty-seven per cent over nine thousand lines is a different claim from eighty-seven per cent over nine hundred, and publishing both makes an over-broad exclusion visible immediately.
// scripts/coverage-context.ts
const summary = JSON.parse(readFileSync('coverage/coverage-summary.json', 'utf8'));
const measured = Object.keys(summary).length - 1;
const allFiles = globSync('src/**/*.{ts,tsx}').filter((f) => !f.includes('.test.')).length;
console.log(`${summary.total.lines.pct.toFixed(1)}% over ${measured} of ${allFiles} source files`);
// 87.4% over 214 of 396 source files
There is one more thing worth reporting alongside the percentage, and it costs nothing: the date the exclusion list last changed. Coverage figures are usually compared against a previous period, and a comparison that spans a change to the denominator is not a comparison at all. Printing the list’s last-modified commit beside the number lets anyone reading a trend see immediately whether a step change was the tests or the definition.
Verification
Verify that every exclusion matches something. A pattern that matches no files is harmless but misleading, and one that matches more than intended is the actual danger.
// scripts/audit-exclusions.ts
import { globSync } from 'glob';
const patterns = ['src/generated/**', 'src/**/*.gen.ts', 'src/**/*.stories.tsx', 'src/main.tsx'];
for (const p of patterns) {
const files = globSync(p, { ignore: ['**/*.test.*'] });
console.log(`${String(files.length).padStart(5)} ${p}`);
if (files.length === 0) console.warn(` ⚠ matches nothing — stale pattern`);
if (files.length > 500) console.warn(` ⚠ matches a great deal — check it is all generated`);
}
Then verify that nothing hand-written is caught, by checking every excluded file for the generated marker. Anything excluded without a marker needs a reason a reviewer accepted.
npx tsx scripts/audit-exclusions.ts --list | while read -r f; do
head -5 "$f" | grep -q "@generated" || echo "excluded but not marked: $f"
done
# excluded but not marked: src/api/retry-policy.ts ← this is hand-written; fix the glob
Finally, verify the exclusion’s effect on the figure is the one you expected. Run with and without and compare; a larger jump than the excluded line count would suggest means the exclusions removed tested code as well as untested.
Troubleshooting
Symptom: coverage jumped several points after an unrelated change. Diagnosis: someone regenerated a client or added an exclusion. Fix: check the diff for changes to the exclusion list and to generated directories before assuming the tests improved — this is the most common cause of an unexplained improvement.
Symptom: a file is excluded although no pattern names it. Diagnosis: the include pattern does not reach it, which excludes by omission rather than by exclusion. Fix: check include first when a file is missing from the report; the two settings interact and omission is the easier one to overlook.
Symptom: generated files still appear despite the glob. Diagnosis: the paths in the report are absolute or prefixed differently from the pattern, a common difference between providers and between local and CI runs. Fix: read one path from the report and write the pattern against that exact shape rather than against the shell’s view of the tree.
Symptom: excluding stories dropped component coverage. Diagnosis: the stories were being used as the tests, through a compose-stories helper, so excluding them removed real coverage of the components. Fix: exclude the story files from the denominator only if they are not the thing exercising the components; if they are, keep them measured and see reusing stories in Vitest with composeStories.
FAQ
Should generated code be excluded or simply given a low threshold?
Excluded. A low threshold keeps it in every report and in every conversation, invites periodic attempts to raise it, and still lets a regeneration move the overall number. Exclusion states the position clearly: this code’s correctness is the generator’s responsibility.
What about code generated once and then edited by hand?
Once a human edits it, it is hand-written and belongs in the denominator, whatever its origin. This is why marker-based exclusion works better than path-based: the convention is that editing a generated file means removing its marker, which moves it back into measurement automatically.
Do exclusions affect mutation testing too?
They should, and for the same reasons — mutating generated code produces survivors nobody will act on and multiplies the run time. Keep the two lists consistent, ideally by deriving both from the same marker scan, as described in keeping mutation testing fast in CI.
Does excluding files make the coverage number dishonest?
Only if the exclusions are hidden. A figure published as “87% over 214 of 396 source files”, with the exclusion list in the repository, is more honest than an unqualified figure over everything — because the second one silently averages code nobody intends to test with code everybody does.
Related
- Back to Defining Coverage Thresholds
- Per-directory coverage thresholds in Vitest — setting the bar once the denominator is right.
- Reporting coverage changes on pull requests — making a jump in the figure explicable.
- Why 100% coverage is the wrong target — the argument behind excluding rather than chasing.