EduFlow
Testing JavaScript Apps
0%

Section 5 · Testing Strategy

Coverage and Its Limits

18 min3 min readCode playground

Lesson at a glance

Measure code coverage with Vitest, read the report to find untested code, and explain why a high coverage number can still hide a weak test suite.

  1. 1Turning it on
  2. 2The four numbers
  3. 3Reading the report
  4. 4What coverage can’t see
  5. 5How much is enough?

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Every team that adds tests eventually asks “how much is enough?”, and coverage is the number that seems to answer. It answers a narrower question than people think. Used well, it’s a fast way to find code nobody tested. Used as a score, it rewards the wrong tests.

Turning it on

Vitest supports two coverage providers. The default, v8, uses the JavaScript engine’s built-in coverage and needs no instrumentation step; istanbul instruments the code and works in more runtimes. Install the provider and run with --coverage:

Terminal
npm install --save-dev @vitest/coverage-v8
npx vitest run --coverage

Configure what to measure and how to report it:

vite.config.js
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      include: ['src/**/*.{js,jsx}'],
      exclude: ['src/**/*.test.{js,jsx}', 'src/main.jsx'],
      reporter: ['text', 'html'],
      thresholds: { lines: 80, branches: 75 },
    },
  },
});

include matters: in Vitest 4, without it, the report lists only files your tests happened to load, so a completely untested module doesn’t appear at all and the percentage looks better than it is. With include, untested files show up at 0%. The html reporter writes coverage/index.html, which you open in a browser.

The four numbers

  • Statements: how many statements ran.
  • Branches: how many sides of each decision ran (if/else, ? :, &&, ||, ??, default parameters).
  • Functions: how many functions were called at least once.
  • Lines: how many lines had something run on them.

Branch coverage is the one I look at. const share = people > 0 ? total / people : 0; is one line and two branches. A test with three people makes the line 100% covered and the branches 50%, and the untested branch is exactly the edge case from section 2.

Reading the report

The HTML report marks lines that never ran in red and branches that were taken only one way in yellow. That’s the valuable part: red code is untested, guaranteed. No assertion anywhere could have checked it, because it never ran. When I join a project, the coverage report is the fastest map of where the risk lives: the uncovered error handling in the payment module, the else branch nobody hit.

A large box of all code; inside it a box of code executed by tests; inside that a smaller box of code whose results tests actually verify. Coverage reports the middle box. All code Executed by tests what coverage reports Results verified what you need
Coverage sees what ran, not what was checked

What coverage can’t see

Coverage records execution. It has no idea whether anything checked the result. This test makes computeBalances 100% covered:

JavaScript
test('computes balances', () => {
  const balances = computeBalances([
    { paidBy: 'Ana', amountCents: 3000, splitAmong: ['Ana', 'Ben', 'Cai'] },
  ]);
  expect(Object.keys(balances)).toHaveLength(3);
});

Every line ran. The assertion would still pass if the payer were debited instead of credited, if the shares were floored and a cent vanished, or if a second expense overwrote the first. Coverage: 100%. Confidence: close to zero. The exercise starts from exactly this test.

An uncovered line is a question, not an order. Sometimes the right answer is a test. Sometimes it’s deleting the code: a branch nobody can reach, a fallback for a browser you stopped supporting, a helper nothing calls any more. Dead code that is “covered” by a test written only to raise the number is the worst outcome, because now it looks important.

How much is enough?

There’s no universal number. Useful rules of thumb:

  • Aim for high branch coverage on core logic (money, permissions, data transformations), and accept less on glue code and UI wiring that end-to-end tests exercise anyway.
  • Exclude code you deliberately don’t unit test (entry points, generated files, type-only modules) so the number means something.
  • Look at coverage on the diff in code review: did this change add untested branches? That’s more actionable than a project-wide percentage.

Coverage tells you where tests are missing. To learn whether the tests you have are any good, you need a different tool, which is the next lesson. First, in the exercise, turn a 100%-coverage, catches-nothing suite into one that catches real bugs.

Key takeaways

  • Coverage measures which statements, branches, functions and lines ran during the tests, not whether anything checked the results.
  • The most useful part of a coverage report is the red and yellow it shows you; uncovered code is untested code for certain.
  • Branch coverage is stricter and more useful than line coverage, because one line can hold several decisions.
  • Use thresholds as a floor that stops coverage from sliding, not as a target to hit; a number that becomes a goal gets gamed.

Further reading

Check your understanding

3 questions

Question 1 of 3

A test calls computeBalances(expenses) and asserts only expect(result).toBeDefined(). Coverage for the file is 100%. What does that tell you?

Question 2 of 3

Line coverage is 100% but branch coverage is 50% for const share = people > 0 ? total / people : 0;. What’s missing?

Question 3 of 3

Your team sets a hard 90% coverage threshold and makes it a quarterly goal. What is the most likely side effect?

Practice

Code playground

From 100% coverage to real confidence

The starter test runs every line of computeBalances (and splitEvenly), so a coverage report would show 100%. Yet it checks only that three names come back.

Strengthen the suite until it catches three hidden bugs that the 100% suite misses: one debits the payer instead of crediting them, one floors each share so leftover cents disappear (balances no longer add up to zero), and one overwrites the payer’s balance instead of adding to it when they pay twice. The rules are in the subject’s comment.

function splitEvenly(totalCents, people) {
  if (!Number.isInteger(people) || people < 1) {
    throw new RangeError('people must be a positive integer');
  }
  const base = Math.floor(totalCents / people);
  const remainder = totalCents % people;
  return Array.from({ length: people }, (_, i) => (i < remainder ? base + 1 : base));
}

// Computes each person's balance in cents from a list of expenses.
// Positive: the group owes them. Negative: they owe the group.
// Each expense is { paidBy, amountCents, splitAmong: [names] }.
// - The payer is credited the full amount.
// - Each person in splitAmong is debited their share from splitEvenly
//   (leftover cents go to the first names in the list).
// - Balances accumulate across expenses and always add up to zero.
function computeBalances(expenses) {
  const balances = {};
  for (const { paidBy, amountCents, splitAmong } of expenses) {
    const shares = splitEvenly(amountCents, splitAmong.length);
    balances[paidBy] = (balances[paidBy] ?? 0) + amountCents;
    splitAmong.forEach((name, i) => {
      balances[name] = (balances[name] ?? 0) - shares[i];
    });
  }
  return balances;
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real computeBalances
  • Catches the version that debits the payer
  • Catches the version that loses leftover cents
  • Catches the version that overwrites the payer’s balance