Skip to main content
Software Testing · lesson 5 of 8
Software Testing intermediate Lesson 5 of 8

Line Coverage Lies: Why Branch Coverage Catches More

A test suite hits 100% line coverage and still misses a real bug. Turning on branch coverage shows exactly which decision path was never exercised.

By SkillByExample · Published

testing coverage coverage.py pytest

A percentage on its own can’t tell you what it’s hiding. A function can report 100% line coverage from a single test, while an entire decision path — and a real bug living inside it — never runs once.

A function with one if and no else

def final_price(price, is_member):
    """10% member discount; $5 shipping credit for orders >= $50 (based on original price)."""
    original = price
    if is_member:
        price = price * 0.9
    if price >= 50:
        price -= 5
    return round(price, 2)
from final_price import final_price

def test_member_order_over_50():
    assert final_price(100, True) == 85.0
$ pytest test_final_price_one.py -v
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 1 item

test_final_price_one.py::test_member_order_over_50 PASSED                [100%]

============================== 1 passed in 0.03s ==============================

One test, one green check. Now measure it with coverage.py, first without branch tracking:

$ coverage run -m pytest test_final_price_one.py && coverage report -m final_price.py
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 1 item

test_final_price_one.py .                                                [100%]

============================== 1 passed in 0.03s ==============================
Name             Stmts   Miss  Cover   Missing
----------------------------------------------
final_price.py       7      0   100%
----------------------------------------------
TOTAL                7      0   100%

100% line coverage from a single test. Every physical line in the function ran — because both if statements are missing an else, the single call with is_member=True, price=100 happens to fall through both if bodies and touch every line on its way to return.

The same run, with branch coverage turned on

$ coverage run --branch -m pytest test_final_price_one.py && coverage report -m final_price.py
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 1 item

test_final_price_one.py .                                                [100%]

============================== 1 passed in 0.03s ==============================
Name             Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------
final_price.py       7      0      4      2    82%   4->6, 6->8
------------------------------------------------------------
TOTAL                7      0      4      2    82%

Same test, same code — but the report drops to 82% and names exactly what’s missing: 4->6 and 6->8. Those are arcs: execution jumping straight from the if line to the line after its body, skipping the body entirely. Neither if statement’s False path — “not a member” or “price below $50” — has ever run. Line coverage couldn’t see this because there was no separate line to miss; branch coverage tracks which way each decision went, not just whether the surrounding lines executed.

Writing the test the report is pointing at — and finding a real bug

The untested combination that matters most: a member order whose original price qualifies for the $50 shipping credit, but whose discounted price does not.

def test_member_order_original_over_50_but_discounted_below():
    # original $55 qualifies for the $5 credit; the function checks the discounted price instead
    assert final_price(55, True) == 44.5
$ pytest test_final_price_borderline.py -v
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 2 items

test_final_price_borderline.py::test_member_order_over_50 PASSED         [ 50%]
test_final_price_borderline.py::test_member_order_original_over_50_but_discounted_below FAILED [100%]

================================== FAILURES ===================================
___________ test_member_order_original_over_50_but_discounted_below ___________

    def test_member_order_original_over_50_but_discounted_below():
        # original $55 qualifies for the $5 credit; the function checks the discounted price instead
        assert final_price(55, True) == 44.5
E       assert 49.5 == 44.5
E        +  where 49.5 = final_price(55, True)

test_final_price_borderline.py:8: AssertionError
=========================== short test summary info ===========================
FAILED test_final_price_borderline.py::test_member_order_original_over_50_but_discounted_below
========================= 1 failed, 1 passed in 0.07s ==========================

Real bug, found by writing the test branch coverage said was missing: $55 * 0.9 = $49.50, and the code checks if price >= 50 after applying the member discount — so a $55 order that should get the $5 shipping credit (its original price is $50+) silently loses it, because by the time the check runs, the discounted price has already dropped below the threshold. The fix checks original instead of the discounted price:

if original >= 50:  # was: if price >= 50
    price -= 5
$ pytest test_final_price_fixed.py -v
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 2 items

test_final_price_fixed.py::test_member_order_over_50 PASSED              [ 50%]
test_final_price_fixed.py::test_member_order_original_over_50_but_discounted_below PASSED [100%]

============================== 2 passed in 0.03s ==============================

Both tests pass now. The bug had been sitting behind a decision path that a 100%-line-covered test suite never noticed was untested — branch coverage is what turned “looks fully tested” into “here’s exactly what isn’t.”

Practice

  1. Close the other missed arc. The report also flagged 6->8 (skipping the shipping credit for non-qualifying orders). Write a test for a non-member order under $50 and confirm it closes that branch.

  2. Measure branch coverage on the fixed function. Run coverage run --branch against final_price_fixed.py with all three tests from this lesson (the original, the borderline, and your answer to task 1). What percentage do you get, and is anything still missing?

  3. A compound condition. def can_checkout(cart, is_verified): return len(cart) > 0 and is_verified — write the fewest tests that reach 100% line coverage. Does coverage.py’s branch mode (which tracks statement-level arcs, not sub-expressions) report anything missing from that same test set? What would you need to test the and’s short-circuit behavior specifically?

  4. Find a real if-without-else in a codebase you have access to (or one of this site’s own lessons) and predict, before running coverage, whether an existing test suite would show a branch gap. Check your prediction.

Solution 1
def test_non_member_order_under_50():
    assert final_price(20, False) == 20.0

This exercises is_member=False (skipping the discount) and original < 50 (skipping the credit) — the 6->8 arc — together in one call.

Solution 2
$ coverage run --branch -m pytest test_all_three.py && coverage report -m final_price_fixed.py
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 3 items

test_all_three.py ...                                                    [100%]

============================== 3 passed in 0.08s ==============================
Name                   Stmts   Miss Branch BrPart  Cover   Missing
--------------------------------------------------------------------
final_price_fixed.py       7      0      4      0   100%
--------------------------------------------------------------------
TOTAL                      7      0      4      0   100%

100% — both if statements now have a test that takes their True arc and a test that takes their False arc, so every arc coverage.py tracks has run at least once.

Solution 3

Two tests reach 100% line coverage: can_checkout([], False) (or any falsy/False combo) and can_checkout([1], True) (a truthy combo) — both execute the single return line. Branch mode reports nothing missing either — in fact it reports zero branches to track at all, because the function has no if statement, just a boolean expression in a return; coverage report shows Branch 0. But that 100% doesn’t mean every sub-case of and was tested — can_checkout([1], False) and can_checkout([], True) were never tried, and and’s short-circuit means the second operand isn’t even evaluated when the first is falsy. Catching that requires condition or MC/DC coverage, a finer-grained metric most tools (including plain coverage.py) don’t measure by default.

Solution 4

(Open-ended — the exact result depends on the codebase chosen.) The general pattern this lesson demonstrates: any if with no matching else, tested by a suite that only ever exercises one side of it, will show 100% line coverage and a branch gap. The more asymmetric a codebase’s testing is toward the “happy path,” the more likely this shows up.

Frequently Asked Questions

What is the difference between line coverage and branch coverage?
Line coverage counts which physical lines of source ran at least once during the test suite. Branch coverage counts which decision outcomes ran — for an `if` statement, both the True path and the False path. A function with an `if` and no `else` can reach 100% line coverage from a single test that only ever takes the True path, because every physical line still gets executed; branch coverage catches this because the False arc (skipping the if-body entirely) never ran.
Does 100% branch coverage mean my tests are good enough?
It means every decision outcome ran at least once — not that the tests assert the right thing when it does. A test can execute a branch and check nothing meaningful about the result. Coverage tells you what code ran; it says nothing about whether your assertions would catch a bug in that code. That gap is what mutation testing measures instead.
Should I always turn on branch coverage?
Yes, for any codebase with conditionals — which is nearly all of them. `coverage.py`'s branch mode is one flag (`--branch` or `branch = True` in `.coveragerc`) and it only ever reports the same or fewer percentage points than line coverage, since a branch is stricter to satisfy. There's no real downside once your test suite already runs under coverage.
Why does the report show something like '4->6' instead of a line number?
That's an arc — coverage.py's notation for 'execution jumped from line 4 to line 6,' skipping whatever was between them. It's how the tool points at exactly which branch never got taken, rather than just saying 'branch coverage is incomplete' and leaving you to guess where.