Coverage Types — Line, Branch, Condition, Path
The previous page, What Coverage Actually Measures, landed on an uncomfortable truth: coverage measures which code your tests executed, not whether that code is correct. But there is a second subtlety hiding inside the word “coverage” — one that trips up teams who proudly report “we’re at 90%.” Ninety percent of what?
There is not one coverage metric. There is a ladder of them, and each rung is strictly stronger than the one below. A test suite can hit 100% on a weak metric while leaving whole classes of behavior untouched — behavior the next metric up would have forced you to test. This page climbs that ladder rung by rung, and at every step shows a concrete bug the weaker metric declares “covered” and the stronger one exposes.
Keep the throughline of this whole book in view as we climb: the question is always how do you earn justified confidence that the code works? A coverage number is a claim about how much of your code the tests reached, and the rung you measured decides how strong that claim is. Reading “90%” without asking which rung is like being told a bridge passed inspection without asking what the inspector actually checked.
The ladder, from weakest to strongest
Section titled “The ladder, from weakest to strongest”Four rungs matter in practice. Each asks a stricter question about your tests:
STRONGER ▲ path coverage — every route through the function taken? │ condition coverage — every boolean sub-expression tried both ways? │ branch coverage — every decision taken both true AND false? ▼ line coverage — every line executed at least once?WEAKERThe key word is strictly stronger: 100% path coverage implies 100% condition, which implies 100% branch, which implies 100% line. The reverse is never true — and every gap between the rungs is a place bugs hide.
Why does the direction only run one way? Because each rung asks about a finer structure than the one below. A line can be executed by a single incoming route (line: satisfied) while one of its exits never fires (branch: not satisfied). A decision can take both its outcomes (branch: satisfied) while an operand inside it never flips (condition: not satisfied). And every condition can be individually exercised (condition: satisfied) while a specific sequence of decisions is never walked end to end (path: not satisfied). Strength always flows downhill; weakness never flows up. Let’s define each rung precisely and then break it with a concrete bug.
Line (statement) coverage
Section titled “Line (statement) coverage”Line coverage — often called statement coverage — asks the weakest possible question: was each executable line run at least once by some test? Divide lines executed by lines executable; that is your percentage. It is the metric almost every tool reports by default because it is the cheapest to compute and the easiest to visualize (green and red lines in your editor).
Its weakness is exactly its simplicity: a line is either touched or not. It says nothing about why control reached that line or what happened when the condition guarding it was false.
Branch (decision) coverage
Section titled “Branch (decision) coverage”Branch coverage — also called decision coverage — asks a harder question: for every decision point (if, while, for, case, ternary), was each outcome taken? An if must be tested with its condition both true and false. A single test that only ever takes the true side leaves the false branch — including the implicit else you didn’t write — uncovered.
Branch coverage catches the biggest, most common gap that line coverage hides: the path you didn’t write code for.
Condition coverage
Section titled “Condition coverage”Condition coverage looks inside compound boolean expressions. A decision like if (a && b) is one branch but two conditions. Condition coverage asks: was each atomic boolean sub-expression (a, b) evaluated both true and false at least once? Full branch coverage can be reached without ever flipping every sub-condition — so a bug in one operand of an && or || can slip through a “100% branch” suite.
The strongest common variant is MC/DC (Modified Condition/Decision Coverage), required by DO-178C for safety-critical avionics: each condition must be shown to independently affect the decision’s outcome. That is condition coverage with proof that no operand is dead weight.
Be careful with the family of names here, because tools use them loosely. Basic condition coverage only asks that each operand be seen both true and false — it does not even guarantee full branch coverage. Condition/decision coverage adds the branch requirement on top. MC/DC goes further still, demanding that each operand be shown to flip the whole decision on its own. When a report says “condition coverage,” the honest follow-up is which of these three? — the gap between them is real tests.
Path coverage
Section titled “Path coverage”Path coverage asks the strongest question: was every distinct route through the function — every combination of branch outcomes from entry to exit — executed? A function with two independent ifs has four paths (T-T, T-F, F-T, F-F). Path coverage demands a test for each. It is the gold standard, and, as we’ll see, almost always infeasible.
100% line coverage, an untested branch
Section titled “100% line coverage, an untested branch”Definitions are cheap. Watch line coverage lie. Here is a small function that clamps a discount to a valid range:
def apply_discount(price, percent): if percent > 100: percent = 100 # cap runaway discounts return price * (1 - percent / 100)Now one test:
def test_apply_discount(): assert apply_discount(200, 150) == 0 # percent capped to 100Run coverage. Every line executed. The if line ran, the percent = 100 line ran, the return line ran. Line coverage: 100%. Green across the board. Ship it?
No. That test only ever entered the if with percent > 100 true. It never ran the function with a normal discount — percent <= 100, the false branch, the overwhelmingly common case in production. Branch coverage is 50%: one of the if’s two outcomes was never taken.
apply_discount(200, 150) → if TRUE ─► percent=100 ─► return ✓ testedapply_discount(200, 10) → if FALSE ───────────────► return ✗ never runAnd the untested branch is where a real bug can live. Imagine a later edit adds a line to the false branch — say a loyalty adjustment percent = percent + 5 — that is only reached when percent <= 100. The single test above never enters that branch, so the new line never runs under test, and coverage still reports 100% line for the code the one test does touch. Nothing warns you that the normal, everyday discount path is completely unexercised. One more test — assert apply_discount(200, 10) == 180 — takes the false branch, closes the decision, and would catch any mistake living in the common path. Line coverage said “done” while half the decisions were untested.
100% branch coverage, an untested condition
Section titled “100% branch coverage, an untested condition”Climb one rung. Branch coverage would have caught the discount gap. But it, too, has a blind spot — compound booleans. Consider an access check:
def can_publish(user): if user.is_admin or user.owns_document: return True return FalseTwo tests:
def test_admin_can_publish(): assert can_publish(User(is_admin=True, owns_document=False)) is True
def test_stranger_cannot_publish(): assert can_publish(User(is_admin=False, owns_document=False)) is FalseThe first test takes the if true; the second takes it false. Both outcomes of the decision are exercised, so branch coverage is 100%. Done?
No — look at the sub-conditions. owns_document was False in both tests. It was never evaluated as True. Condition coverage is incomplete: one operand of the or was never flipped.
is_admin owns_document || result tested?admin test True False True ✓stranger test False False False ✓document owner False True ??? ✗ NEVER RUNNow the bug. Suppose a refactor accidentally changes the check to if user.is_admin: — dropping the owns_document clause entirely. Both branch-covering tests still pass: the admin is still allowed, the stranger still denied. Your “100% branch coverage” suite is fully green while document owners have silently lost the ability to publish. Only a test that makes owns_document the deciding factor — can_publish(User(is_admin=False, owns_document=True)) — exposes it. That is exactly what condition coverage (and MC/DC) forces you to write.
The general shape of this trap is worth memorizing, because compound booleans are everywhere authorization, pricing, and rules live. Whenever a decision is an and/or of several operands, branch coverage can be satisfied by flipping the whole expression while one operand quietly stays pinned to a single value. That pinned operand is dead weight the tests never probe — and dead weight is precisely where a dropped clause, an inverted comparison, or a copy-paste error survives. The stronger the boolean, the wider this blind spot: if (a || b || c || d) can reach 100% branch coverage with b, c, and d never once being the reason the answer changed.
Why full path coverage is (almost) impossible
Section titled “Why full path coverage is (almost) impossible”Path coverage is the strongest metric, so why doesn’t everyone target it? Because the number of paths is combinatorial, and loops make it infinite.
Every independent binary decision doubles the path count. A function with n independent ifs has 2^n paths:
decisions (n) paths (2^n)───────────── ──────────── 3 8 10 1,024 20 1,048,576 30 1,073,741,824Thirty simple decisions — utterly ordinary for a real function — is over a billion paths, each needing its own test. And that assumes no loops. A single while loop that can run 0, 1, 2, … times adds a new path per iteration count, so a loop with a data-dependent bound has an unbounded number of paths. Full path coverage of code with loops is not merely expensive; it is literally infinite. This is the same wall as the infinite input space: you cannot enumerate what is unbounded.
So teams pick a practical rung. The industry consensus for most software is branch coverage as the meaningful floor — it is far stronger than line coverage yet still linear in the number of decisions, so it stays affordable. Safety-critical domains climb one rung further to condition/MC/DC and pay for it deliberately. Nobody chases full path coverage as a suite-wide target; instead they cover the high-risk paths explicitly and let the rest fall out of good branch and condition tests.
The economics are the whole argument. A test is not free: someone writes it, it runs on every CI build forever, and it must be maintained as the code changes. Branch coverage buys most of the confidence — it forces the untaken-branch tests where the majority of real bugs hide — at a cost that scales linearly with the code. Path coverage buys a little more confidence at a cost that scales exponentially, so past a handful of decisions you are spending enormous test effort to close paths that will almost never be walked in production. The right move is not “test every path” but “name the paths whose failure would hurt, and test those on purpose” — which is judgement, not a metric.
Under the hood — how tools measure each rung
Section titled “Under the hood — how tools measure each rung”The rungs differ in strength because they differ in what the instrumentation records. Understanding the mechanism explains why line coverage is everywhere and MC/DC is rare.
A coverage tool works by instrumenting your code — inserting invisible counters — then running your tests and reading the counters back:
- Line coverage places one counter per executable line. After the run, any line whose counter is still zero is “uncovered.” Cheap: one probe per line, and the number the tool reports is just
nonzero lines / total lines. - Branch coverage places a counter on each edge out of a decision — the true edge and the false edge of every
if, eachcase, each loop entry/exit. A branch is uncovered if either edge’s counter is zero. This is why a tool can show a line as “green” (executed) yet flag it with a partial-branch marker: the line ran, but one of its exits never did. - Condition coverage must instrument inside the boolean expression, tracking the truth value of each operand separately. This requires the tool to defeat short-circuit evaluation — in
a && b, ifais false,bis never evaluated, so the tool must record thatb’s “true” and “false” cases may still be outstanding. That extra bookkeeping is why many mainstream tools report line and branch by default but leave condition/MC/DC to specialized (often safety-certified) toolchains. - Path coverage would require a counter per sequence of edges, and there is no finite set of counters for an unbounded number of paths — which is the mechanical restatement of why it is infeasible.
The practical takeaway: when a report says “coverage 87%,” the first question is always “which rung?” An 87% line number and an 87% branch number are very different promises, and an 87% condition number is stronger still.
A decision rule: which rung to target
Section titled “A decision rule: which rung to target”You don’t pick one metric for everything. You match the rung to the risk of the code:
kind of code target rung────────────────────────────────────────── ───────────────────────throwaway scripts, glue, prototypes line (or skip)ordinary business logic, services, web apps branch ← the default floorcompound-boolean-heavy logic (auth, pricing, condition rules engines, feature flags)safety- or money-critical, regulated MC/DC / condition, (avionics, medical, payments, ledgers) plus explicit high-risk pathsThe rule in one sentence: branch coverage is the default floor for real code; step up to condition coverage wherever a wrong boolean has real consequences; reserve path-level attention for the specific high-risk routes you can name. Anything below branch coverage is barely measuring your tests at all; anything demanding full path coverage is chasing infinity.
Notice that the decision is about risk, not about hitting a bigger number. The rung is a tool for spending scarce test effort where a bug would cost the most — a wrong price, a leaked permission, a mis-fired radiation dose — and spending less where it would cost little. That is the same judgement the rest of this part keeps sharpening: coverage informs the decision, but it never makes the decision for you.
And remember the ceiling the whole part keeps returning to: even 100% of the strongest affordable metric is not a correctness proof — the next page shows why coverage tells you which code ran, never whether it ran correctly.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because “coverage” is ambiguous — a suite can be 100% “covered” on a weak metric while whole behaviors go untested. The ladder of coverage types exists to make the strength of a coverage claim precise and comparable.
- What problem does it solve? It exposes the gaps a single metric hides: line coverage misses untaken branches, branch coverage misses untried sub-conditions, and both miss dangerous path combinations. Each rung forces tests the rung below lets you skip.
- What are the trade-offs? Strength costs tests, and the cost grows: line→branch is roughly linear, branch→path is combinatorial and, with loops, infinite. You trade confidence for test count and CI time, so you buy only the rung the code’s risk justifies.
- When should I avoid it? Avoid chasing condition/path coverage on low-risk glue code — the tests cost more than the bugs would. And never treat any rung’s percentage as a proof of correctness; see Goodhart’s Law.
- What breaks if I remove it? Report only line coverage and you get false comfort: teams celebrate “90%” while half their decisions and most of their compound-boolean logic sit untested — precisely where auth, pricing, and safety bugs live.
Check your understanding
Section titled “Check your understanding”- Define line, branch, condition, and path coverage in one sentence each, and state the “strictly stronger” relationship between them.
- Show, with a small
if, how a suite can reach 100% line coverage while branch coverage is only 50%. Which branch is typically the one left untested? - A test suite has 100% branch coverage on
if (a || b). Explain how a bug that deletes thebclause can still ship undetected, and what single test would catch it. - Why is full path coverage infeasible for real code? Give the specific reason involving
ndecisions, and the separate reason involving loops. - State the decision rule for which coverage rung to target, and name the kind of code for which you’d climb above the default floor.
Show answers
- Line/statement: every executable line ran at least once. Branch/decision: every decision point took each outcome (true and false). Condition: every atomic boolean sub-expression was evaluated both true and false. Path: every distinct route (combination of branch outcomes) from entry to exit was executed. They are strictly stronger going up: 100% path ⇒ 100% condition ⇒ 100% branch ⇒ 100% line, and never the reverse.
- A test that enters an
ifonly on its true side runs every line (the condition, the body, the code after) yet never takes the false outcome — so line coverage is 100% but branch coverage is 50%. The untested branch is usually the false/elsecase, including the implicitelseyou never wrote, which is often the common production path. - If both tests that achieve branch coverage happened to leave
balwaysfalse(the decision flipped only viaa), then removing thebclause changes no test result — thea-driven cases still pass. The fix is a test wherebis the deciding factor:a=false, b=true, which must now return true and fails ifbwas dropped. That is condition (MC/DC) coverage. - With
nindependent binary decisions there are 2^n paths, which grows exponentially — 30 decisions is over a billion paths, each needing a test. Separately, a loop whose iteration count is data-dependent creates a new path per iteration count, giving an unbounded (infinite) number of paths. So full path coverage is combinatorially explosive and, with loops, literally impossible. - Match the rung to the code’s risk: branch coverage is the default floor for ordinary business logic; step up to condition/MC/DC wherever a wrong boolean has real consequences — auth, pricing, rules engines, and safety- or money-critical regulated systems — and reserve explicit path-level tests for the specific high-risk routes you can name.