Coverage Metrics
Line, statement, branch, condition and path coverage — what each measures, why branch coverage is worth three times line coverage, and what none of them see.
2 min read · updated 19 September 2026
"Coverage" usually means line coverage, which is the weakest of the useful metrics. The differences matter because they decide what a green number actually tells you.
#The ladder
Consider:
function shippingCents(order: Order, customer: Customer): number {
if (customer.tier === 'gold' || order.subtotalCents >= 5_000) {
return 0;
}
return order.isExpress ? 995 : 395;
}Statement / line coverage — did each line execute?
it('is free for a gold customer', () => {
expect(shippingCents(anOrder({ subtotalCents: 1_000 }), aCustomer({ tier: 'gold' }))).toBe(0);
});
it('is 395 standard', () => {
expect(shippingCents(anOrder({ subtotalCents: 1_000, isExpress: false }), aCustomer())).toBe(395);
});Both lines executed. 100% line coverage. And the express path is untested, and the £50 threshold is untested.
Branch / decision coverage — was each outcome of each decision taken?
Now the isExpress ? : ternary counts as a decision with two outcomes, so
the above sits at 75% branch coverage and the report points directly at the
express case. Adding it gets to 100% branch.
The £50 threshold is still untested, because || short-circuits: the
first test satisfied the condition via tier === 'gold' and never evaluated
the second operand.
Condition coverage — was each sub-condition evaluated both ways? This is what catches the threshold. Now you need a non-gold customer at £50 and one below it.
MC/DC — was each sub-condition shown to independently change the outcome? Required for DO-178C avionics; overkill elsewhere.
Path coverage — was every combination of branches taken? Combinatorially
explosive; a function with ten independent ifs has 1,024 paths. Not
practical, and rarely informative.
#The practical recommendation
Use branch coverage as the primary signal. It is roughly three times more informative than line coverage for the same effort, and it is much harder to satisfy by accident.
// Jest / Vitest
coverageThreshold: {
global: { branches: 70, statements: 80 } // branches lower, and harder
}<!-- JaCoCo -->
<rule>
<element>BUNDLE</element>
<limits>
<limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.70</minimum></limit>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.80</minimum></limit>
</limits>
</rule># coverage.py
[tool.coverage.run]
branch = true
[tool.coverage.report]
show_missing = true<!-- coverlet, .NET -->
<PropertyGroup>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<Threshold>70</Threshold>
<ThresholdType>branch</ThresholdType>
</PropertyGroup>Note that Python's coverage.py has branch coverage off by default, and
a great many projects report line coverage believing it to be more.
#What the report will not show
None of these metrics see:
Assertions. Executing a line proves nothing about whether its result was checked. This is the gap mutation testing fills.
Data-dependent behaviour. A function covered at 100% branch with
quantity = 1 may still overflow at quantity = 2_147_483_647.
Property-based testing addresses this — see
Python for Hypothesis.
Implicit branches. a?.b?.c has two branches; x ?? y has two. Some
tools count these, some do not, and a TypeScript codebase full of optional
chaining can show a branch percentage that varies by tool.
Integration. Every line of two modules can be covered while the modules do not fit together. Only an integration test sees that.
Concurrency. The interleaving that causes the bug is not a branch.
#A worked reading
File Stmts Branch Missing branches
pricing.ts 100% 64% L23 (else), L41 (both), L58 (true)
refunds.ts 92% 40% L12,15,19,22,30,33,41,44 (else)pricing.ts at 100% statements and 64% branches: the happy paths are
covered and a third of the decisions have only been taken one way. Those
untaken branches are, in almost every codebase, the error handling.
refunds.ts at 40% branch with eight missing else paths is a clear
statement: every failure path in the refund logic is untested. That is a
more useful finding than any total, and you only get it by looking at branch
coverage line by line.
Common questions
- What is the difference between line and branch coverage?
- Line coverage asks whether a line executed. Branch coverage asks whether each possible outcome of each decision was taken. A single-line if statement with no else can reach 100% line coverage with the false path never exercised, which is exactly the case branch coverage catches.
- Which coverage metric should I use?
- Branch coverage as the primary signal, line coverage as context. Branch is meaningfully harder to satisfy accidentally and correlates much better with whether the error paths were tested.
- What is MC/DC coverage?
- Modified condition/decision coverage — every sub-condition in a compound boolean must be shown to independently affect the outcome. It is required for safety-critical avionics software under DO-178C and is overkill almost everywhere else.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/coverage/coverage-metrics
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Code CoverageWhat the percentage measures, why it is a finding tool rather than a target, how to collect it in each ecosystem, and how to gate on it without causing harm.
- Mutation TestingDeliberately break the production code and see whether the tests notice — the only practical measurement of whether a test suite actually asserts anything.
- End-to-End Code CoverageInstrument the application, run Playwright or Cypress against it, and merge the result with the unit run — the measurement that shows which code only your slowest tests protect.
- Unit TestingWhat a unit actually is, what belongs in a unit test and what does not, and the properties that separate a unit suite people run from one they skip.