Skip to content
Chapter 86Lesson 3

Coverage as a diagnostic, not a target

Reading Vitest coverage reports to locate untested code instead of chasing a number.

The last lesson set the bar: not “we have tests,” but “do the tests fail on the bugs that ship.” To tell whether your suite clears it, you reach for coverage, the single most misread number in testing.

Picture the report reading 87% lines, 72% branches. One engineer takes it as a grade: 87% passes, so set the CI gate at “≥80% lines” and push toward 100%. The other ignores the average and asks which files, which lines, because that 87% can hide a webhook receiver at 40% behind a one-line getter at 100%. This lesson teaches the second read, using the report as a diagnostic that locates the untested seam before it ships. You’ll add a coverage block to the vitest.config.ts from the first lesson, with thresholds where coverage means something and an include that surfaces untested files.

Coverage measures which parts of your source ran while the suite executed, and nothing more. The report splits “which parts” into four numbers: lines (which source lines executed), statements (which statements ran, close to lines, but several can share a line), functions (which functions were called at least once), and branches (which sides of each decision were taken).

The rest of the lesson turns on one sentence. Coverage records what executed, never what was checked. A line can run inside a test that asserts nothing and still count as covered: it ran, so it shows green, and the report cannot tell whether anything downstream was verified.

The numbers come from the @vitest/coverage-v8 provider you installed in the first lesson. It reads coverage data that V8 already produces during a normal run, so there is no instrumentation step rewriting your source. That makes it fast, and provider: 'v8' is already the default.

Turning the report on takes a provider and a reporter:

vitest.config.ts
// inside test: { ... }
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},

Run it with the pnpm test:coverage script from the first lesson. Of the four numbers it prints, one is worth reading first, and it isn’t the one most people reach for.

It’s tempting to treat the line percentage as the headline number, but line coverage is the measure least connected to where bugs live.

Line coverage counts whether each line executed. Branch coverage counts whether each path through every decision was taken: each side of an if, each case, each side of an && or ?: that can short-circuit, each catch that may or may not fire. The two diverge constantly, because one test can run every line of a function while taking only one side of its decisions.

Take a guard you’ve written many times:

export function loadReport(role: Role) {
if (role !== 'admin') {
return forbidden();
}
return buildReport();
}

Call loadReport('admin') once and every line runs, so line coverage reports 100%. But the test only takes the false side of role !== 'admin'; the forbidden() branch never fires. Branch coverage reports 50%, and the branch it flags as missing is the authorization denial, the path whose absence ships as a bug. This is the branch the fail-closed reflex from the errors-and-security work cares about most: the deny is exactly what line coverage can’t see.

lines
loadReport.ts
branches
1 export function loadReport(role: Role) {
2 if (role !== 'admin') {
false ✓ true ✕
3 return forbidden();
4 }
5 return buildReport();
5 / 5 — 100%
one test: loadReport('admin')
1 / 2 — 50%
covered missed
Every line ran; only one side of the decision did. Line coverage says 100%, branch coverage says 50% — and the missing branch is the denial.

So the rule is this: read branch coverage first. Line coverage near 100% with branch coverage well below it is the signature of a suite that calls the code without testing its decisions, and the decisions are where the seam bugs are.

If branch coverage is the number to read first, the goal might seem to be pushing every number to 100%. It isn’t: a 100%-coverage badge is a yellow flag, not a gold star.

The cost is obvious. Reaching 100% means a test for every getter, every defaulted parameter, every error class’s name, and every branch the framework injects that your code never chooses, all for near-zero bug-finding signal. You spend real hours turning green a body of code nobody was going to break.

The deeper problem is corruption. Coverage measures what ran, not what was checked, so 100% is fully achievable by a suite that asserts almost nothing: call each function with a fixture, let the lines tick over, never check the result. A 100% culture quietly turns your test code from a contract about behavior into a mirror of the source, and once the team senses the tests only echo the implementation, they stop trusting them. You have paid for a suite and bought noise.

These tests take a few recognizable shapes, and they all pass and all look like work:

  • it('exports the function', () => expect(typeof fn).toBe('function')) confirms the function exists but asserts nothing about what it does.
  • A snapshot that captures whatever the function returned today and asserts nothing about behavior.
  • A test that checks a function’s return type matches its declared type. The type system already guarantees that, so the test is redundant the moment it compiles.
  • A test that mocks every dependency and then asserts the mocks were called with the values the test itself just wired in. It tests the wiring, not the function.

One question separates these from real tests, and it is worth carrying everywhere: what would have to change for this test to fail meaningfully? If the only honest answer is “delete the test,” it is theatre. A real test fails when the behavior breaks; a theatre test fails only when it is removed.

Try it on the file below. You are reviewing a small test suite, most of it theatre. Comment on each test that isn’t pulling its weight, naming why it is theatre, not just that it is.

You're reviewing a teammate's new test file. Three of these four tests add coverage but no signal — they pass, they tick lines green, and they fail only if you delete them. Leave a comment on each one you'd flag, naming *why* it's theatre. Click any line to leave a review comment, then press Submit review.

src/lib/billing.test.ts
import { expect, it, vi } from 'vitest';
import { formatPlan, chargeCustomer } from './billing';
it('exports formatPlan', () => {
expect(typeof formatPlan).toBe('function');
});
it('returns a string', () => {
const result = formatPlan('pro');
expect(typeof result).toBe('string');
});
it('formats the pro plan label', () => {
expect(formatPlan('pro')).toBe('Pro plan');
});
it('charges the customer', () => {
const stripe = { charge: vi.fn() };
chargeCustomer(stripe, 4200);
expect(stripe.charge).toHaveBeenCalledWith(4200);
});

One tool measures what coverage can’t, and it is worth knowing as a mental model. Mutation testing (the tool is Stryker) flips operators in your source and asks whether any test notices: coverage measures what ran; mutation measures what’s checked. Stryker is overkill for most web app suites, so take the idea and leave the tool: when you ask “what would have to change for this test to fail,” you are running the mutation test in your head.

Read the report to find the under-tested seam

Section titled “Read the report to find the under-tested seam”

Read correctly, the report is the fastest way to find load-bearing code paths your suite never exercised, and those paths cluster at the seams.

The report isn’t a scoreboard, it is a map of un-exercised paths, and you read a map by location, not by average. Scan the per-file breakdown for under-coverage in the seams: the catalog you built last lesson is the list to scan, authedAction’s catch branch, the webhook receiver’s signature-failure path, safeLimit ’s fail-open carve-out, the cross-tenant 404 branch, the error mapper’s fallback case. Each uncovered branch there is a line a missing test will ship as a production bug.

One contrast makes this concrete. A 100%-covered /lib mapper next to a 40%-covered Server Action wrapper is the signature of a suite that ships bugs: all the effort pooled at the safe base, none at the dangerous seam. The average hides it; the per-file view makes it impossible to miss.

In the figure below, your eye should go straight to the red rows, and within them, to the gap between the line and branch columns.

File
Lines
Branches
Uncovered
lib/money.ts
100%
100%
lib/invoice-mapper.ts
96%
92%
41, 58
lib/auth/authed-action.ts
100%
45%
22–29
app/api/webhooks/stripe/route.ts
40%
30%
18–52
lib/rate-limit.ts
88%
52%
31–37
headline average 85% lines · 64% branches — the one number that tells you nothing
authed-action.ts → 100% lines, 45% branches — the denial branches never ran. The trap line coverage hides.
stripe/route.ts → Your highest-stakes seam, your least-covered file.
healthy under-covered seam
Don't read the average — a healthy-looking headline number across this table would tell you nothing. Read which seams are red.

To do this, run pnpm test:coverage, which writes an HTML report under coverage/ via the default html reporter. Open it, drill into a seam file, and read which lines and branches are red; the top-line percentage is the one number that can’t help you. The two reporters split the work: text prints the terminal summary a teammate or CI scans, html is the developer’s drill-down. Treat this as a periodic diagnostic, once a sprint and whenever you touch a seam, not a per-save habit that slows every run for nothing.

On a pull request, the signal a reviewer wants is differential, not absolute. Differential coverage answers “this PR added 20 lines and covered 15 of them,” where “the overall number moved 0.4%” is noise. Vitest can ratchet thresholds up automatically with coverage.thresholds.autoUpdate, but the course doesn’t use it: the churn outweighs the gain.

Per-directory thresholds as a backstop, not a goal

Section titled “Per-directory thresholds as a backstop, not a goal”

A reading discipline that lives only in your head erodes the moment you stop looking, so you encode a piece of it into CI, carefully, because a threshold framed wrong recreates every problem this lesson just dismantled.

A threshold is a backstop, not a target you climb toward: a floor that catches a previously-tested seam losing coverage, like someone adding an else with no test for it. You write tests for behaviors that exist, and the threshold catches the regression. So it goes only where coverage means something, /lib purity and the seams, and nowhere on framework-mediated surfaces, where chasing coverage is the theatre you already know to avoid.

In the course baseline, every threshold ships with a one-line justification, since a number without a reason is a number nobody can defend later:

  • src/lib/**, 90% lines, 85% branches. Pure logic, the wide base of the honeycomb; if it lives in /lib, it is testable, so it should be near-fully covered.
  • src/app/api/webhooks/**, 85% branches. The highest-stakes seam; every uncovered branch is a webhook that mishandles a real provider event.
  • src/lib/auth/**, src/lib/error-mapping.ts, src/lib/rate-limit.ts, 85% branches. Load-bearing helpers; an uncovered branch here is an auth bypass, a leaked stack trace, or a fail-open that should have failed closed.
  • Everything else, deliberately unthresholded.

One tooling trap: in Vitest 4, per-glob thresholds are keys inside coverage.thresholds and no longer inherit the top-level perFile setting, so if a glob needs per-file checking, set perFile on that glob’s own object.

The exclusion list is the paired idea. Thresholds declare where coverage matters; coverage.exclude declares where it is pure noise, the files whose number would only ever mislead. The course excludes config files (**/*.config.{ts,js}), type-only files (**/*.d.ts, **/types.ts), barrel files (**/index.ts), framework-orchestrated route files (app/**/page.tsx, app/**/layout.tsx, tested through integration at the seam rather than by re-testing the framework’s rendering), Storybook stories (**/*.stories.tsx), one-off scripts (scripts/**), and mock directories (**/__mocks__/**). As with thresholds, every exclusion carries a recorded reason: an unexplained exclude is how a seam quietly disappears from the report and rots out of sight.

Walk through the assembled block one part at a time:

// inside test: { ... }
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
thresholds: {
'src/lib/**': { lines: 90, branches: 85 },
'src/app/api/webhooks/**': { branches: 85, perFile: true },
'src/lib/auth/**': { branches: 85 },
},
exclude: [
'**/*.config.{ts,js}',
'**/*.d.ts',
'app/**/{page,layout}.tsx',
'**/*.stories.tsx',
],
},

First, produce the report: the v8 provider and two reporters, text for the terminal summary and html for the drill-down you open when you are hunting a seam.

// inside test: { ... }
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
thresholds: {
'src/lib/**': { lines: 90, branches: 85 },
'src/app/api/webhooks/**': { branches: 85, perFile: true },
'src/lib/auth/**': { branches: 85 },
},
exclude: [
'**/*.config.{ts,js}',
'**/*.d.ts',
'app/**/{page,layout}.tsx',
'**/*.stories.tsx',
],
},

A floor on the wide base. Pure logic in /lib should be near-fully covered, so 90% lines and 85% branches catches a regression where someone ships untested logic into it.

// inside test: { ... }
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
thresholds: {
'src/lib/**': { lines: 90, branches: 85 },
'src/app/api/webhooks/**': { branches: 85, perFile: true },
'src/lib/auth/**': { branches: 85 },
},
exclude: [
'**/*.config.{ts,js}',
'**/*.d.ts',
'app/**/{page,layout}.tsx',
'**/*.stories.tsx',
],
},

Branch floors on the seams. These guard the denial and fail-closed branches, the webhook’s signature-failure path and the auth wrapper’s reject path, the exact branches line coverage hides.

// inside test: { ... }
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
thresholds: {
'src/lib/**': { lines: 90, branches: 85 },
'src/app/api/webhooks/**': { branches: 85, perFile: true },
'src/lib/auth/**': { branches: 85 },
},
exclude: [
'**/*.config.{ts,js}',
'**/*.d.ts',
'app/**/{page,layout}.tsx',
'**/*.stories.tsx',
],
},

The Vitest 4 detail: glob thresholds don’t inherit the top-level perFile, so you opt in per glob. Here it means every webhook file clears 85%, not just the average across them, so one untested receiver can’t hide behind the others.

// inside test: { ... }
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
thresholds: {
'src/lib/**': { lines: 90, branches: 85 },
'src/app/api/webhooks/**': { branches: 85, perFile: true },
'src/lib/auth/**': { branches: 85 },
},
exclude: [
'**/*.config.{ts,js}',
'**/*.d.ts',
'app/**/{page,layout}.tsx',
'**/*.stories.tsx',
],
},

Strip the noise. Config, type-only, and framework-orchestrated files only ever report misleading numbers, so each entry is coverage you refuse to let count against you.

1 / 1

That config catches regressions on surfaces you already chose to care about; it cannot tell you whether a test asserts the right thing, so the reading discipline is still the actual work.

Now practice the distinction the section turns on: which files earn a coverage threshold, and which are exempt.

Sort each file by whether it earns a coverage threshold or is exempt from the report. Ask: if this file lost coverage, would a real bug be hiding behind the number? Drag each item into the bucket it belongs to, then press Check.

Gets a coverage threshold Load-bearing — coverage here means something
Excluded or unthresholded Framework-mediated or pure noise
lib/money.ts
lib/auth/authed-action.ts
app/api/webhooks/stripe/route.ts
lib/rate-limit.ts
app/dashboard/page.tsx
env.config.ts
components/ui/card.tsx
lib/types.ts

Coverage reports on what ran. A file that no test ever imports never runs during the suite, so it never appears in the report. That makes it worse than the trivially-tested file: a file with one line-covering test at least reads as 100%, while a file with no test doesn’t even drag the average down, because in Vitest 4’s default it is simply absent. The most dangerous file in your codebase can be the one the report never mentions.

The fix is one config change, and the behavior changed in Vitest 4: by default the report includes only files loaded during the run. The old coverage.all: true, which reported every file, was removed. Set coverage.include to globs covering your load-bearing surface, and any matched file that was never imported shows up at 0% instead of vanishing:

vitest.config.ts
// inside test: { ... }
coverage: {
include: ['src/lib/**/*.ts', 'src/app/api/**/*.ts'],
},

But include only makes the gap visible; it can’t close it. For every file in /lib and /app/api, confirm a test sits beside it (or in tests/integration/ for seams that cross modules) and exercises the public surface. The config surfaces the 0% rows; you still write the missing test.

One honest carve-out. A brand-new feature behind a flag may legitimately ship under-tested in its first sprint, while its surface is still moving. The move is to time-box the gap out loud: add the directory to a temporary exclude, ship it, write the tests in the follow-up PR, remove the exclude. The discipline was never “always 90%,” it is naming the gap instead of hiding it behind a coverage line that happens to clear the threshold.

Here is the finished coverage block in one place:

vitest.config.ts
// inside test: { ... }
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/lib/**/*.ts', 'src/app/api/**/*.ts'],
thresholds: {
'src/lib/**': { lines: 90, branches: 85 },
'src/app/api/webhooks/**': { branches: 85, perFile: true },
'src/lib/auth/**': { branches: 85 },
'src/lib/error-mapping.ts': { branches: 85 },
'src/lib/rate-limit.ts': { branches: 85 },
},
exclude: [
'**/*.config.{ts,js}',
'**/*.d.ts',
'**/types.ts',
'**/index.ts',
'app/**/{page,layout}.tsx',
'**/*.stories.tsx',
'scripts/**',
'**/__mocks__/**',
],
},

One question before you move on.

A teammate opens a PR that raises the project’s overall coverage from 84% to 91% and points to it as evidence the suite got stronger. You pull up the report. Which observation would most justify pushing back?

The branch coverage is still trailing the line coverage, so a handful of decisions remain untested.
The seven new points all landed on /lib getters now sitting at 100%, while the webhook receiver and the auth wrapper haven’t moved off 40%.
The PR didn’t also switch on coverage.thresholds.autoUpdate, so the new floor isn’t locked in.
91% still leaves a measurable slice of untested code, so the work isn’t finished until it reaches 100%.

Coverage finds untested seams; it can’t grade them. It tells you what your tests ran, never whether they asserted the right thing — and the assertion is what decides whether a test is worth keeping. You can hit 100% and assert nothing, or sit at 70% with a suite that catches every bug that matters.

So the next lesson teaches the assertion itself: Arrange, Act, Assert; one behavior per test; an assertion that fails on the real bug and survives a refactor that doesn’t. Coverage found the seam; now you write the test that holds it.