Branch vs Line vs Statement Coverage: What Each Metric Actually Measures

Branch vs Line vs Statement Coverage: What Each Metric Actually Measures

Coverage tools report four or five different metrics, and most teams pick one number to track without fully understanding what the others measure. That's a problem because each metric catches different categories of untested code, and conflating them gives you false confidence.

This is the complete breakdown.

Statement Coverage (C0)

Statement coverage counts whether each executable statement in your source code has been executed at least once.

def process_payment(amount, account):
    log.info("Processing payment")          # statement 1
    if amount <= 0:                          # statement 2
        raise ValueError("Invalid amount")  # statement 3
    balance = account.get_balance()         # statement 4
    if balance < amount:                    # statement 5
        return {"status": "insufficient"}   # statement 6
    account.deduct(amount)                  # statement 7
    return {"status": "success"}            # statement 8

To achieve 100% statement coverage, every line must execute. Two tests cover this:

def test_insufficient_balance():
    account = Account(balance=50)
    result = process_payment(100, account)
    assert result["status"] == "insufficient"

def test_successful_payment():
    account = Account(balance=200)
    result = process_payment(100, account)
    assert result["status"] == "success"

Statement 3 (the ValueError) never executes. Statement coverage is 87.5% (7/8 statements), but both tests pass.

The weakness: statements in branches that never execute count as uncovered, but so do entire branches. Statement coverage doesn't tell you which conditional paths were tested.

Line Coverage (C1)

Line coverage counts whether each line of source code was executed. In most languages, it's nearly identical to statement coverage — one statement per line. The difference emerges with multi-statement lines:

// JavaScript — one line, two statements
if (x > 0) return x; else return -x;

// This counts as one line covered even if only the true branch executes

Some tools (including Istanbul for JavaScript) report line coverage and statement coverage separately. When a line contains multiple statements, you can have the line "covered" while some of its statements go unexecuted.

In practice, treat line coverage and statement coverage as equivalent unless your codebase heavily uses single-line multi-statement forms.

Branch Coverage (C2)

Branch coverage — also called decision coverage — tracks whether each possible outcome of every decision point has been executed. For every if, while, for, ternary, and logical operator, both the true and false outcomes must be tested.

Using the same payment function:

def process_payment(amount, account):
    if amount <= 0:                          # decision 1: true, false
        raise ValueError("Invalid amount")
    balance = account.get_balance()
    if balance < amount:                     # decision 2: true, false
        return {"status": "insufficient"}
    account.deduct(amount)
    return {"status": "success"}

There are 4 branch outcomes:

  1. amount <= 0 is True → raise exception
  2. amount <= 0 is False → continue
  3. balance < amount is True → insufficient
  4. balance < amount is False → success

Our two previous tests cover outcomes 2, 3, and 4. Outcome 1 (invalid amount) is never tested. Branch coverage is 75%.

This is the most practically useful metric. A function can have 100% line coverage with 50% branch coverage — meaning half your conditional logic is untested.

Condition Coverage

Condition coverage goes deeper than branch coverage. Instead of tracking decision outcomes, it tracks whether each individual boolean sub-expression has evaluated to both true and false.

if user.is_active and user.has_permission('write'):
    allow_access()

This has one branch (the if) but two conditions:

  • user.is_active
  • user.has_permission('write')

Branch coverage requires the overall if to be both true and false. Condition coverage also requires each sub-expression to be both true and false, independently.

To achieve full condition coverage, you need at least three tests:

  1. Both true → branch taken
  2. is_active is False → branch not taken
  3. has_permission is False → branch not taken

A subtlety: due to short-circuit evaluation, if is_active is False, has_permission never evaluates. This means you can't achieve full condition coverage on short-circuit expressions without multiple test cases.

MC/DC Coverage (Modified Condition/Decision Coverage)

MC/DC is the aviation standard (DO-178C) for safety-critical software. It's stricter than condition coverage and is required for FAA-certified avionics software.

MC/DC requires:

  1. Every condition in a decision takes on all possible outcomes
  2. Every condition independently affects the decision outcome
  3. Every entry and exit point is invoked

"Independently affects" is the key part. For each condition C, there must be two test cases that differ only in C, where C's value change causes the overall decision to change.

For A && B:

Test A B Result
1 T T T
2 F T F
3 T F F

Three tests satisfy MC/DC for a two-condition expression. For N conditions in a single decision, MC/DC requires N+1 test cases — significantly fewer than the 2^N required for full path coverage.

Most commercial software doesn't need MC/DC, but understanding it clarifies why condition coverage isn't sufficient for complex logical expressions.

Path Coverage

Path coverage tracks every unique execution path through a function — every combination of branches. For two independent decisions, there are 4 paths. For three decisions, there are 8.

function classify(x, y) {
  let result = '';
  if (x > 0) result += 'positive-x';  // decision 1
  if (y > 0) result += 'positive-y';  // decision 2
  return result;
}

Four paths:

  1. x > 0, y > 0 → 'positive-xpositive-y'
  2. x > 0, y ≤ 0 → 'positive-x'
  3. x ≤ 0, y > 0 → 'positive-y'
  4. x ≤ 0, y ≤ 0 → ''

Branch coverage requires only that each decision's true and false outcomes are tested — 2 tests can cover both decisions (x > 0 and y ≤ 0 in test 1, x ≤ 0 and y > 0 in test 2). Path coverage requires all 4 combinations.

Path coverage is exponential in the number of decisions. For any non-trivial function, complete path coverage is infeasible. It's a theoretical metric more than a practical target.

Practical Implications: Which Metric to Use

For most projects: Track branch coverage. It catches what line/statement coverage misses (unexercised conditional paths) while remaining achievable. Target 75-85%.

For safety-critical code: Consider MC/DC requirements. Libraries handling financial transactions, medical data, or security operations benefit from the rigor of independent condition testing.

For understanding existing gaps: Run statement coverage first to find completely untested code, then drill into branch coverage to understand which paths through tested code are missing.

What to ignore: Path coverage as a target metric. It's exponentially expensive to achieve and the marginal value beyond branch coverage is low for most business logic.

The Hierarchy

Coverage strength, from weakest to strongest:

Statement ⊂ Line ⊂ Branch ⊂ Condition ⊂ MC/DC ⊂ Path

A test suite satisfying branch coverage necessarily satisfies statement coverage. A test suite satisfying MC/DC necessarily satisfies condition coverage. The reverse is not true.

When your coverage tool reports a single percentage, it's almost always statement or line coverage. Before drawing conclusions from that number, check which metric the tool is reporting. Istanbul reports statements, branches, functions, and lines separately. JaCoCo reports instructions, branches, lines, methods, and classes. Knowing which number is which determines what the gap in your tests actually is.

Short-Circuit Evaluation and Coverage

A consistent source of confusion: logical AND/OR with short-circuit evaluation creates branches that coverage tools count differently.

function isEligible(user) {
  return user.age >= 18 && user.hasAccepted && !user.isBanned;
}

Istanbul counts this as having multiple branches — one for each && operator. You need tests where:

  • user.age >= 18 is false (short-circuits, others don't evaluate)
  • user.age >= 18 is true but user.hasAccepted is false
  • Both are true but user.isBanned is true
  • All conditions satisfied

That's 4 test cases for branch coverage on one return statement. If you only have two tests (one that returns true, one that returns false), you'll have 50% branch coverage on this function even though both outcomes are tested.

This is why branch coverage numbers often seem unreasonably low. The fix is writing explicit tests for each short-circuit scenario, which has the added benefit of documenting exactly when each condition gets evaluated.

Read more

Start now free