4.5.15 · D4Software Engineering

Exercises — Code coverage — line, branch, path coverage

3,875 words18 min readBack to topic

Level 1 — Recognition

L1.1 — Name the unit

Problem. For each coverage level (line, branch, path), state in one phrase what unit sits in the denominator.

Recall Solution
  • Line coverage — denominator = total executable lines.
  • Branch coverage (= decision coverage) — denominator = total branch outcomes (each decision = 2).
  • Path coverage — denominator = total feasible end-to-end paths (see the "feasible path" rule in the intro — infeasible routes are not counted).

Memory hook (see Code Coverage — Line, Branch, Path Coverage mnemonic): "Branch = both ends, Path = every blend."

L1.2 — Count the denominators

Problem. Given:

def sign(n):
    if n > 0:        # D1
        return "pos"
    if n < 0:        # D2
        return "neg"
    return "zero"

Count (a) executable lines, (b) branch outcomes, (c) decisions.

Recall Solution

(a) Executable lines. if n>0, return "pos", if n<0, return "neg", return "zero"5 lines. (The def header is not executable code that "runs logic"; we count the 5 statements.) (b) Branch outcomes. Two decisions (D1, D2), each with True/False → 4 branch outcomes. (c) Decisions. D1 and D2 → 2 decisions.


Level 2 — Application

L2.1 — One test, three percentages

Problem. Using sign from L1.2, run a single test sign(5). Compute line %, branch %, and identify the untaken branch outcomes (keep units separate from lines).

Recall Solution

sign(5): 5 > 0 is True → runs if n>0 then return "pos". It stops there (early return).

Lines (unit = executable line, denominator 5):

  • Executed: if n>0, return "pos" → 2 of 5.
  • Untaken lines: if n<0, return "neg", return "zero".

Branch outcomes (unit = branch outcome, denominator 4) — a different denominator, kept separate:

  • Taken: only D1-True → 1 of 4.
  • Untaken branch outcomes (the exact answer the prompt asks for): D1-False, D2-True, D2-False.

Notice we never mix the two lists: lines live in the 5-denominator, outcomes in the 4-denominator.

L2.2 — Reach 100% line and 100% branch

Problem. Find the smallest set of test inputs for sign that gives 100% branch coverage. Confirm it also gives 100% line, and explain why branch coverage drags line coverage up with it.

Recall Solution

We must take both outcomes of D1 and both outcomes of D2.

  • sign(5): D1=True → returns before D2. Covers D1-True, lines if n>0,return "pos".
  • sign(-5): D1=False, D2=True. Covers D1-False, D2-True, lines if n<0,return "neg".
  • sign(0): D1=False, D2=False. Covers D2-False, line return "zero".

Three tests hit all 4 branch outcomes → branch, and every line ran → 100% line.

Why full branch forces full line (intuitive proof): every executable line lives on some branch — either the True side or the False side of the decision that guards it (a line with no guard sits on the always-entered "main" branch, which is trivially taken whenever the function runs). To reach 100% branch you must walk every True side and every False side at least once. But walking a branch means executing every line inside it. So once all branches are walked, no line has been skipped — line coverage is automatically 100%. The reverse fails: you can execute the single line if n>0 (100% of that line) while only ever taking its True outcome, leaving the False outcome — and its guarded lines — untouched. That is why branch ⊇ line but not the other way. See the figure below.

Figure — Code coverage — line, branch, path coverage

L2.3 — Path count with a loop

Problem. How many feasible paths does this have, if the loop body runs 0, 1, or 2 times only (input restricted)?

def g(items):
    total = 0
    for it in items:   # 0..2 iterations
        total += it
    return total
Recall Solution

A for is a decision "enter body?" checked each time. The distinct paths are:

  • 0 iterations (empty list),
  • 1 iteration,
  • 2 iterations. → 3 paths. If iterations were unbounded (), path count is infinite — this is exactly why path coverage is often infeasible for loops. The figure below shows this fan-out.
Figure — Code coverage — line, branch, path coverage

Level 3 — Analysis

L3.1 — Branch vs Path gap

Problem. For the parent note's function

def f(a, b):
    if a: x = 1
    else: x = 2
    if b: y = 1
    else: y = 2
    return x + y

Tests are f(True, True) and f(False, False). Compute branch % and path %, and name the untested paths.

Recall Solution

Branch: outcomes are D1{T,F}, D2{T,F} = 4. (T,T) gives D1-T, D2-T; (F,F) gives D1-F, D2-F. All 4 hit → branch. Paths: two sequential independent ifs → paths: (T,T),(T,F),(F,T),(F,F). Tested: (T,T),(F,F). Untested: (T,F) and (F,T) path. A bug that only fires when a=True and b=False slips past 100% branch.

Figure — Code coverage — line, branch, path coverage

L3.2 — Short-circuit masking

Problem.

if user is not None and user.active:   # C1 and C2
    login()

Tests: user = None, and user = ActiveUser. Compute decision (branch) coverage and condition coverage. Which sub-condition is under-tested?

Recall Solution

Decision coverage (= branch coverage) = the whole condition's T/F.

  • user=None: C1 is False → short-circuits → whole decision False.
  • user=ActiveUser: C1 True, C2 True → whole decision True. → both decision outcomes seen → decision/branch.

Condition coverage = each sub-term needs both T and F.

  • C1 = (user is not None): seen False (None) and True (ActiveUser) → covered.
  • C2 = user.active: only ever True (in the None case, short-circuit means C2 is never evaluated). C2=False is never tested. → condition coverage = . C2 (user.active) is under-tested. Fix: add user = InactiveUser so C2 evaluates to False. For safety-critical code use MC/DC (Modified Condition/Decision Coverage), which additionally requires each condition to independently flip the whole decision — see MC-DC Coverage.

L3.3 — Mutation cross-check

Problem. A test suite gives 100% line and 100% branch on grade:

def grade(x):
    if x > 50: return "pass"
    return "fail"

The tests are grade(60) and grade(40) — but neither asserts anything. A mutation flips > to >=. Would any test fail? What does this reveal?

Recall Solution

With no assert, both tests just run the code. Flipping > to >= changes behaviour only at x=50 — and no test uses 50, and no test checks the return value anyway. No test fails. The mutation survives. Reveals: coverage measured execution (100%) but the suite has 0 assertions → a vanity metric. Mutation Testing exposes this by killing mutants; here the surviving mutant proves the suite is worthless despite 100% coverage.


Level 4 — Synthesis

L4.1 — Design a minimal 100%-branch suite

Problem. Design the smallest test set giving 100% branch coverage for:

def classify(t):
    if t < 0:            # D1
        return "invalid"
    if t < 37:           # D2
        return "cold"
    elif t <= 39:        # D3
        return "normal"
    else:
        return "fever"
Recall Solution

Decisions: D1(t<0), D2(t<37), D3(t<=39). Each needs T and F. Pick inputs walking each region:

  • t = -1 → D1-True ("invalid").
  • t = 20 → D1-False, D2-True ("cold").
  • t = 38 → D1-F, D2-F, D3-True ("normal").
  • t = 41 → D1-F, D2-F, D3-False ("fever").

Check outcome tally: D1 T&F ✓, D2 T&F ✓, D3 T&F ✓ → all 6 outcomes covered by 4 tests branch.

Why 4 is minimal (concrete argument). Each test is a single input t, and one input walks exactly one route to a return, so it produces exactly one outcome for each decision it reaches. There are 4 distinct return statements"invalid", "cold", "normal", "fever" — and each is reachable only by a mutually exclusive range of t: t<0, 0≤t<37, 37≤t≤39, t>39. No single input can lie in two of these ranges at once, so no single test can trigger two returns. To hit branch outcome D3-False you must reach "fever" (needs t>39); to hit D2-True you must reach "cold" (needs t<37) — these are different tests. Working through all six outcomes, the outcomes D1-True, D2-True, D3-True, D3-False each force a distinct return, so you need at least those 4 returns → at least 4 tests. We achieved it with exactly 4, so 4 is minimal. ∎

L4.2 — Compute path count and pick the tests that add value

Problem. For f(a,b) from L3.1, you already have (T,T) and (F,F). Which two additional tests reach 100% path? What is the total then? Sketch how the test count grows if there were such ifs.

Recall Solution

Missing paths were (T,F) and (F,T). Add f(True, False) and f(False, True). Now all paths executed → path, using 4 tests total.

The exponential blow-up, made concrete. Each independent if doubles the number of complete routes: 1 if → 2 paths, 2 ifs → , 3 ifs → , ..., ifs → . Full path coverage needs one test per path, so the test count doubles with every added decision. The figure below plots this — branch coverage stays linear ( outcomes) while path coverage climbs exponentially (), which is why we reserve path/MC-DC for small, safety-critical cores.

Figure — Code coverage — line, branch, path coverage

Level 5 — Mastery

L5.1 — Cyclomatic complexity as a branch-test lower bound

Problem. For classify (L4.1), the Cyclomatic Complexity is where = number of decisions. Compute , and explain intuitively why bounds the number of tests needed for branch coverage.

Recall Solution

Decisions D1, D2, D3 → , so

Where comes from (intuitive derivation). Draw the code as a Control Flow Graph: nodes are statements, edges are "flow to next". Start with the single straight-line spine — that is 1 path. Now every decision adds a fork — a brand-new edge peeling off to an alternative route that did not exist before. Each fork you add gives you one more independent way through the graph. Start at 1 (the spine) and add 1 per decision → independent paths. Because these independent paths between them traverse every edge (every branch outcome) of the graph, walking of them is enough to take every branch outcome at least once. So is an upper bound on the tests a smart tester needs for branch coverage (and a lower bound on independent-path testing).

Sanity check against L4.1: our minimal branch suite for classify used exactly 4 tests, matching . The figure below shows the spine-plus-forks picture.

Figure — Code coverage — line, branch, path coverage

L5.2 — Strategy judgement: coverage + mutation together

Problem. Module A: 95% branch, 90% of mutants killed. Module B: 100% branch, 40% of mutants killed. Which module's test suite is more trustworthy, and why? What single action helps B most?

Recall Solution

Module A is more trustworthy. Branch coverage is a lower bound on ignorance — B's 100% only proves every outcome executed, not that results were checked. B's low mutant-kill rate (40%) reveals missing assertions: mutations survive because tests don't verify behaviour. A, despite slightly lower coverage, actually detects 90% of injected bugs. Best single action for B: add assertions on outcomes (or adopt Test-Driven Development / Unit Testing discipline so every test checks a result), then re-run Mutation Testing. Raising B's coverage from 100% to "100%" does nothing; raising its kill rate does everything.

L5.3 — Feasible vs infeasible paths

Problem.

def h(a):
    if a > 10: x = 1   # D1
    else: x = 0
    if a > 20: y = 1   # D2
    else: y = 0
    return x + y

There are syntactic paths. How many are feasible, and what is true path coverage's maximum?

Recall Solution

Recall from the intro: a path is feasible only if some real input walks it; it is infeasible when its required choices contradict one another. Here the two decisions are not independent: a > 20 mathematically implies a > 10 (any number above 20 is automatically above 10). So a path demanding D1=False (i.e. a ≤ 10) together with D2=True (i.e. a > 20) asks for a number that is both ≤ 10 and > 20 — impossible.

Checking all four combinations:

  • (D1-T, D2-T) — needs a>10 and a>20: e.g. a = 25feasible.
  • (D1-T, D2-F) — needs a>10 and a≤20: e.g. a = 15feasible.
  • (D1-F, D2-F) — needs a≤10 and a≤20: e.g. a = 5feasible.
  • (D1-F, D2-T) — needs a≤10 and a>20: no such numberinfeasible.

So feasible paths = 3, not 4. True path coverage's maximum is — you can reach 100% by covering exactly those 3 with tests a ∈ {25, 15, 5}. If a tool naïvely used the syntactic denominator 4, the best you could ever score would be , permanently stuck below 100% through no fault of the tests. Lesson: the path denominator must count only feasible paths; correlated conditions shrink it, and a good coverage tool must prune the impossible combination before reporting.

Figure — Code coverage — line, branch, path coverage

Recall Master recall — one line each

Line % of sign(5) on the 5-line sign function ::: 40% Branch % of a single test taking 1 of 4 outcomes ::: 25% Untaken branch outcomes after sign(5) ::: D1-False, D2-True, D2-False Tests needed for 100% branch on sign ::: 3 (e.g. 5, -5, 0) Path % of f with tests (T,T),(F,F) ::: 50% Condition coverage % in the short-circuit login example ::: 75% Minimal tests for 100% branch on classify (4 exclusive returns) ::: 4 Cyclomatic complexity of classify (3 decisions) ::: 4 Feasible paths of h where a>20 implies a>10 ::: 3