This is Part 18 of the “Frontend Testing, Done Right” series. Browse the full series · Glossary

Open a coverage report and four numbers stare back — Stmts, Branch, Funcs, Lines. Which one matters? This article is about reading that report: which number is honest (spoiler: branch), what to fill in from the red lines, and where to look for the team health that numbers can’t show.

If you’ve been following the series, this is the hands-on follow-up to the strategy part’s point that “coverage is a reference, not a target.” First time here from a search? That’s fine too — we use Vitest, but reading a report works the same no matter which tool you’re on.

Practice code: this part’s state is pinned to the step-18 tag. The coverage config already landed back in the setup part, so the code is identical to step-17 — just run npm run coverage.

Today’s goal is an eye for the numbers.

  • Line vs branch vs function coverage
  • Finding the gaps in a report
  • Health metrics beyond coverage
A coverage report terminal output with the Branch column highlighted in blue - All files shows 100 for Stmts, Funcs and Lines but only 86.36 for Branch, and a side panel explains what each of the four numbers asks. Below, notes explain that fully covered files are omitted from the table and that coverage/index.html shows everything
A coverage report terminal output with the Branch column highlighted in blue - All files shows 100 for Stmts, Funcs and Lines but only 86.36 for Branch, and a side panel explains what each of the four numbers asks. Below, notes explain that fully covered files are omitted from the table and that coverage/index.html shows everything

Meet the four numbers

The report’s four pillars, read like this:

  • Stmts (statements) — the share of executable units your tests passed through. Moves almost in lockstep with Lines
  • Branch — whether you took both sides of every fork: if/else, ternaries, &&/||
  • Funcs (functions) — the share of defined functions called at least once
  • Lines — the share of code lines executed. Most intuitive — and, as you’ll see, most generous

One thing to keep: three of the four ask “did you pass through?” — only branch asks “did you take both sides?”


Turning it on

The environment part’s config already carries it. One trap first — skip the exclude and your mocks and setup files join the denominator, inflating the number. Count app code only. (repo vite.config.ts)

ts
coverage: {
  provider: 'v8',
  reporter: ['text', 'html'],
  // measure app code only: mocks, setup, entry are not coverage targets
  exclude: ['node_modules/', 'dist/', 'e2e/', 'src/mocks/', 'src/test/', 'src/main.tsx', '*.config.ts'],
}
bash
npm run coverage

 % Coverage report from v8
-----------------|---------|----------|---------|---------|-------------------
File             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files        |     100 |    86.36 |     100 |     100 |
 src/components  |     100 |    78.57 |     100 |     100 |
  UserSearch.tsx |     100 |    78.57 |     100 |     100 | 16-22,58
-----------------|---------|----------|---------|---------|-------------------

That’s the demo repo’s actual report. “Is that all the files?” — the text table omits fully covered files. users.ts, debounce.ts and friends are all at 100%, so their rows are hidden; they’re still inside the All files totals. Which makes this table effectively a “list of under-filled files.” For the full sweep, open the html reporter (coverage/index.html) — each file shows unexecuted lines in red.


Watch the branch number

Lines alone will fool you. Let’s experiment with the demo’s filterByQuery.

ts
export function filterByQuery(users: User[], query: string): User[] {
  const k = query.trim().toLowerCase()
  if (!k) return users          // ← the fork
  return users.filter(...)
}

Say we have only one test: “searching bob filters the list.” Nearly every line executes, so Lines reads generously high. But at the if (!k) fork we always walked the “no” side — the “yes” path of an empty query, never once. Only branch coverage honestly reports that as 50%. Add an empty-query test, and both finally read 100%.

A diagram of the difference between line and branch coverage - with only one query test almost every line runs so line coverage reads high, but the empty-query path of the if statement was never taken so branch stays at 50 percent. Adding one boundary test visits both sides and both numbers reach 100
A diagram of the difference between line and branch coverage - with only one query test almost every line runs so line coverage reads high, but the empty-query path of the if statement was never taken so branch stays at 50 percent. Adding one boundary test visits both sides and both numbers reach 100

This is where the unit testing part’s boundary obsession (“what if it’s empty? missing?”) gets confirmed in numbers.


Now let’s actually read that table

Theory in hand, back to the real report. Stmts, Funcs, Lines — all 100% — and Branch alone at 86.36%. Exactly what we just learned: three of them gave full marks for “passed through,” and only branch reported “there are forks you haven’t taken.”

Chase the two ranges in Uncovered Line #s back to the code and you get a perfect judgment exercise.

  • Line 58 — the “no search results” screen when the query matches zero users. Wait — no test ever verifies this screen. And it’s a path users actually hit. → Worth filling
  • Lines 16–22 — the “already gone” side of the guard (if (active)) that blocks a server response arriving after the component unmounted. Reproducing it means artificially juggling unmount timing, and the logic is one line. → Fine to skip for now
A side-by-side comparison of the two gaps - line 58, the no-results screen, is hit often and easy to test so it is worth filling now, while lines 16 to 22, the far side of the if (active) guard, happens rarely and needs unmount timing rigged, so it can be left for now
A side-by-side comparison of the two gaps - line 58, the no-results screen, is hit often and easy to test so it is worth filling now, while lines 16 to 22, the far side of the if (active) guard, happens rarely and needs unmount timing rigged, so it can be left for now

Same red lines, different weights. The report shows you the gaps; a human decides which to fill — and the criterion is “how likely is a user to hit this.”


For finding gaps — and beyond the numbers

Coverage’s proper use isn’t score-flaunting; it’s finding untested, risky forks. Skim the report’s red lines and fill only the ones that matter.

And team health often shows better in other metrics: flaky rate, average test runtime, mean time to recovery (MTTR). Watch those trends in the reports from the CI pipeline you just built.


One-page summary

  • exclude first — mocks and setup in the denominator inflate the number
  • Of the four, branch is most honest — lines fill up even when an if goes one way
  • The text table omits fully covered files — it’s effectively a “list of under-filled files”; the full sweep lives in the html report
  • Red lines in the html report (coverage/index.html) = places no test has visited — same red, different weights (how likely users hit it), so fill only the risky forks
  • The proper use is gap-finding, not score-flaunting — the moment 100% becomes the goal, the strategy part’s traps start growing
  • Team health lives beyond coverage too: flaky rate, runtime, MTTR — watch them in CI reports

Wrapping up

Chapter 4 complete. Now for this series’ highlight — testing in the AI era.

The fastest way to push a coverage badge to 90%… isn’t writing tests. It’s excluding untested files from the report. (Let’s not.)

Level up: you can read a coverage report and fill the gaps that actually matter.

Next up: generating tests with AI — verification stays human

Bumped into an unfamiliar term? The glossary has them all, one line each.