Skip to content
End To End Tester
Coveragepractical

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:

typescript
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?

typescript
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.

javascript
// Jest / Vitest
coverageThreshold: {
  global: { branches: 70, statements: 80 }    // branches lower, and harder
}
xml
<!-- 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>
ini
# coverage.py
[tool.coverage.run]
branch = true

[tool.coverage.report]
show_missing = true
xml
<!-- 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 ↗
  • TypeScripttypescript/src/coverage/coverage-metrics

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?