Software Testing · lesson 5 of 8
Beginner
1Intermediate
4Advanced
3Also part of Software Testing
12- + Testing Python Code from Python
- + Unit Testing with JUnit 5 and Mockito from Java
- + Testing JavaScript with Jest and Vitest from JavaScript
- + Testing in Go from Go
- + Testing in Rust from Rust
- + Testing with MUnit and ScalaCheck from Scala
- + Contract Testing with OpenAPI, and Running in CI from API Testing
- + Your First Playwright Test from Playwright
- + Flaky Tests: Staleness, Retries, and CI from Selenium
- + Testing Data Pipelines from Data Engineering
- + terraform test: Catching a Wrong Assertion, Not Just a Wrong Plan from Terraform
- + Testing TypeScript from TypeScript
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
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
-
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. -
Measure branch coverage on the fixed function. Run
coverage run --branchagainstfinal_price_fixed.pywith 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? -
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. Doescoverage.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 theand’s short-circuit behavior specifically? -
Find a real
if-without-elsein 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.