What you'll learn
Quick Answer
There is no universal percentage: 80-90% branch coverage on logic-heavy code is a reasonable default, with steeply diminishing returns after that. More importantly, coverage only proves a line executed, not that a test checked the right answer, so a high number by itself is not proof of quality.
The wrong first question
"What percentage of coverage should we require?" is the wrong first question, because it assumes coverage measures something it doesn't. Code coverage tracks which lines, branches, and functions executed while your tests ran. It says nothing about whether the test checked that the result was correct. A test can run every line of a function and still not catch a single bug, if its assertions are weak or missing entirely. That distinction matters more than the number itself, and it is the one most "we need 80% coverage" mandates skip.
None of this makes coverage useless — a low number reliably tells you where nobody has written any test at all, which is a real and useful signal. The trouble starts when a high number gets treated as proof of quality, rather than as what it actually is: a map of what ran, not what was verified.
A real gap in a real report
Take a real function with a branch nobody has tested yet:
function getDiscount(user) {
if (user.isStudent) {
if (user.referralCode) {
return 20;
}
return 10;
}
return 0;
}Two tests cover the non-student case and the plain-student case. Running vitest with @vitest/coverage-v8 against exactly that gives a real report:
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
discount.js | 80 | 75 | 100 | 80 | 4Line 4 — the return 20 inside the referral branch — never ran. That is a genuine, specific gap: nobody knows if a student with a referral code gets the right discount, because it has never been checked. Add one test that calls getDiscount with a referral code, rerun the same command, and the report reads 100% across every column. So far, coverage has done exactly its job by pointing at a line nobody exercised.
Where 100% coverage stops meaning anything
Here is where it goes wrong. The test that got coverage to 100% was written like this:
it('handles a referral code', () => {
const result = getDiscount({ isStudent: true, referralCode: 'FRIEND10' });
expect(result).toBeDefined();
});It calls the function with a referral code, so the line executes and coverage counts it. But it never checks what the function returned, only that it returned something. Change the source to return 999 instead of 20 — a real bug, the kind a copy-paste or a stray constant produces — and rerun the full suite with coverage:
Test Files 1 passed (1)
Tests 3 passed (3)
Statements : 100% ( 5/5 )
Branches : 100% ( 4/4 )100% coverage. All tests green. The function is wrong by a factor of fifty. This is the natural result of writing a test to satisfy a coverage number rather than to check a value. Fix the assertion to expect(result).toBe(20) and the same broken code now fails immediately, exactly as it should.
So how much coverage is actually enough
There is no single correct percentage, but there are useful defaults. For code with real business logic — pricing, permissions, scoring, anything with branches that matter — 80 to 90% branch coverage is a reasonable bar, because at that level most remaining gaps are genuinely low-risk. For thin wrapper code, generated code, and UI glue that mostly delegates elsewhere, chasing the same number wastes effort for little safety gained.
Past roughly 90%, returns drop sharply: the remaining lines are usually rare error paths or defensive checks that are awkward to reach in a test, and forcing coverage of them tends to produce more throwaway assertions like the one above, not more real tests.
Treat the percentage as a signal to investigate, not a target to hit. A sudden drop after a change is worth asking about. A specific uncovered branch in payment or auth code is worth fixing today. A flat 82% that has held steady for months, on a codebase with solid tests for its critical paths, is not a problem that needs solving.
Line coverage and branch coverage aren't the same thing
Line coverage and branch coverage answer different questions, and a report that only shows line coverage hides real gaps. Line coverage asks whether a line executed at all. Branch coverage asks whether every path through a conditional executed, both the true and false side of an if, not just one of them. A function with if (a && b) can hit 100% line coverage with a single test, while branch coverage would show the case where a is true and b is false was never exercised.
When reading reports, treat branch coverage as the number that matters and line coverage as the easier, less informative one. Most modern tools, including @vitest/coverage-v8, report both side by side for exactly this reason. Use the gap between them as a second signal, not just the headline number.
There is a stricter measure still, called path coverage, which counts every distinct route through a function rather than every individual branch, and it grows exponentially with each added condition, so tools rarely report it directly. In practice, a healthy branch coverage number combined with a mutation testing pass, which checks whether the assertions would actually notice a wrong answer, gets you most of what path coverage promises without the combinatorial cost.
