PYTH-230 · Lesson 11 of 12 · 24 min

Code coverage and test strategy

▶ Video coming soon A video version of this lesson is on the way.

By the second week of December, Ridgeline's test suite had grown to 140 tests, and they all passed. Rosa asked a fair question: is that enough? Are there parts of the code that no test ever touches? Jordan had no idea. A count of tests says nothing about what they cover; 140 tests could all be checking the loyalty tiers while the order import goes untested.

Code coverage measures which parts of the code run while the tests run. It is a useful tool and an easily misused number. This lesson explains line and branch coverage and how to measure them with coverage.py, what coverage can and cannot tell you, how mutation testing checks the tests themselves, how to choose test cases systematically, and how to put these ideas together into a test strategy: deciding what to test, how much and at which level.

Measuring coverage

The standard tool is coverage.py, installed with pip. It runs your tests while watching which lines of your program execute, then reports, for each file, how many statements ran and which ones did not. With pytest, the pytest-cov plugin does the same through an option.

A coverage report: statements per file, how many never ran, and which lines
> coverage run -m pytest
================ 140 passed in 1.84s ================
> coverage report -m
Name                        Stmts   Miss  Cover   Missing
---------------------------------------------------------
src/ridgeline/giftcard.py      24      0   100%
src/ridgeline/inventory.py     61      3    95%   88-90
src/ridgeline/loyalty.py       18      0   100%
src/ridgeline/orders.py        74     31    58%   41-60, 77-89, 102
src/ridgeline/storage.py       39      6    85%   55-61
---------------------------------------------------------
TOTAL                         216     40    81%

> pytest --cov=ridgeline --cov-report=term-missing     # the same, through pytest-cov

The Missing column is the useful part. Lines 41 to 60 of orders.py never ran during the tests: that turned out to be the code that handles an order with no shipping address, exactly the kind of case that went wrong on that Saturday in lesson 7. Lines 88 to 90 of inventory.py were an error branch for a corrupted stock file. coverage html produces a web page with every file shown line by line, run lines in green and missed lines in red, which makes gaps easy to see.

The fee function shown as a coverage report. Lines that ran during the tests have a green bar; the raise line in the empty-order branch has a red bar. The if express line has a gold bar and a note: partial branch, the false direction was never taken. At the bottom: line coverage 5 of 6 statements, branch coverage 3 of 6 decision directions.
Line coverage shows which lines ran; branch coverage also shows which way each decision went

Line coverage and branch coverage

Line coverage, also called statement coverage, counts the lines that ran. It has a blind spot: an if statement whose condition was always true in the tests counts as covered, even though the case where it is false was never tried. Branch coverage counts each possible direction of every decision separately, so it reports that missing case. coverage.py measures branches when run with the --branch option, and it is worth turning on.

One test runs every line, but half the branches are untested
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")

def fee(subtotal, express):
    cost = 0 if subtotal >= 5000 else 795
    if express:
        cost += 1000
    return cost

class TestFee(unittest.TestCase):
    def test_small_express_order(self):
        self.assertEqual(fee(3000, True), 1795)

report(TestFee)
print("every line ran, yet fee(3000, False) and fee(6000, ...) were never tried")
Output
test_small_express_order           ok
1 tests: 1 passed, 0 failed
every line ran, yet fee(3000, False) and fee(6000, ...) were never tried

This single test executes every line of fee, so line coverage is 100. But it never tries a non-express order, the false branch of if express, and never tries a large order, the other side of the conditional expression. A bug in either case would go unnoticed. Branch coverage would report the gaps.

To see how a coverage tool works underneath, the listing below uses sys.settrace, the hook that debuggers and coverage.py are built on. Python calls the trace function for every line executed, and the function records the line numbers that belong to the function being measured.

A tiny line-coverage tool built on sys.settrace
import sys

def shipping(subtotal):
    if subtotal <= 0:
        raise ValueError("empty order")
    if subtotal < 2500:
        return 1295
    if subtotal < 5000:
        return 795
    return 0

def lines_run(func, *calls):
    """Relative line numbers of func executed while making the calls."""
    seen = set()
    start = func.__code__.co_firstlineno
    def tracer(frame, event, arg):
        if frame.f_code is func.__code__ and event == "line":
            seen.add(frame.f_lineno - start)
        return tracer
    sys.settrace(tracer)
    try:
        for args in calls:
            func(*args)
    finally:
        sys.settrace(None)
    return sorted(seen)

all_lines = list(range(1, 8))      # the seven lines of the body
ran = lines_run(shipping, (1000,), (3000,))
print("ran:   ", ran)
print("missed:", [n for n in all_lines if n not in ran])
Output
ran:    [1, 3, 4, 5, 6]
missed: [2, 7]

Two test inputs, 1000 and 3000 cents, leave the empty-order error and the free-shipping return unexecuted: relative lines 2 and 7. Adding tests for 0 and 6000 would cover them. Real tools do the same thing much more efficiently and across a whole project.

What coverage cannot tell you

Coverage tells you what the tests did not check: a line that never ran was certainly not tested. It cannot tell you that covered code was tested well. A test that calls a function and asserts nothing at all still produces full coverage of that function. Coverage measures which code was executed, not which behavior was verified.

Full coverage, no assertions, an obvious bug undetected
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")

def points(total_cents):
    if total_cents <= 0:
        return 0
    return total_cents * 100        # a bug: should be // 100

class TestPointsWeak(unittest.TestCase):
    def test_runs(self):
        points(1850)                # no assertion: covers lines, checks nothing
        points(-5)

report(TestPointsWeak)
print("coverage: 100 out of 100 lines; bugs found: none")
Output
test_runs                          ok
1 tests: 1 passed, 0 failed
coverage: 100 out of 100 lines; bugs found: none

This is why coverage targets can backfire. If a team is told that coverage must reach 90, people write tests that execute code without checking it, and the number goes up while the protection does not. The economist Charles Goodhart's observation applies: when a measure becomes a target, it ceases to be a good measure. Use coverage to find untested areas and ask why, not as a score to maximize. Many teams settle on a reasonable floor, such as 80, enforced in continuous integration to stop coverage from slipping, while reviewing the quality of tests by reading them.

Mutation testing: testing the tests

If coverage cannot judge test quality, what can? Mutation testing makes many small deliberate changes to the code, called mutants: changing < to <=, + to -, a constant 100 to 101, deleting a line. For each mutant it runs the tests. If a test fails, the mutant is killed, which is good: the tests noticed the change. If all tests still pass, the mutant survived, which reveals behavior the tests do not actually check. Tools such as mutmut and cosmic-ray automate this for Python.

Hand-made mutants: weak tests let them survive, boundary tests kill them all
def tier(points):
    if points >= 300:
        return "Reserve"
    if points >= 100:
        return "Roast"
    return "Bean"

def weak_tests(f):
    return f(0) == "Bean" and f(500) == "Reserve" and f(150) == "Roast"

def strong_tests(f):
    return all(f(p) == t for p, t in [(99, "Bean"), (100, "Roast"), (299, "Roast"), (300, "Reserve")])

mutants = {
    ">= 300 became > 300": lambda p: "Reserve" if p > 300 else "Roast" if p >= 100 else "Bean",
    ">= 100 became > 100": lambda p: "Reserve" if p >= 300 else "Roast" if p > 100 else "Bean",
    "100 became 101":      lambda p: "Reserve" if p >= 300 else "Roast" if p >= 101 else "Bean",
}
for name, mutant in mutants.items():
    print(f"{name:<22} weak: {'survived' if weak_tests(mutant) else 'killed':<9} strong: {'survived' if strong_tests(mutant) else 'killed'}")
Output
>= 300 became > 300    weak: survived  strong: killed
>= 100 became > 100    weak: survived  strong: killed
100 became 101         weak: survived  strong: killed

Every mutant survives the weak tests, even though those tests cover every line of tier, because none of them sits on a boundary. The boundary tests kill them all. Mutation testing is slow, since it runs the whole suite once per mutant, so teams usually run it occasionally or on the most important modules rather than on every commit.

Choosing test cases systematically

Good test cases are not random. Three techniques find most of what matters. Equivalence partitioning divides the possible inputs into groups that the code should treat the same way, such as orders under 2500 cents, from 2500 to 4999, and 5000 and up, and tests one representative from each. Boundary value analysis adds the values at and on either side of every boundary between groups, such as 2499, 2500, 4999 and 5000, because off-by-one mistakes cluster there. Error guessing adds the inputs experience says cause trouble: zero, negative numbers, empty lists, empty strings, None, very large values, and text with accented characters.

A fourth technique, property-based testing, lets a library generate hundreds of inputs automatically and checks properties that must hold for all of them, such as the shares of a split bill always adding up to the total. The Hypothesis library brings this to Python and is remarkably good at finding edge cases people miss.

A property-based test: Hypothesis tries many generated inputs
# tests/test_split.py, using the Hypothesis library
from hypothesis import given, strategies as st
from ridgeline.money import split_bill

@given(total=st.integers(min_value=0, max_value=10_000_000),
       people=st.integers(min_value=1, max_value=50))
def test_shares_add_up_and_differ_by_at_most_one(total, people):
    shares = split_bill(total, people)
    assert sum(shares) == total
    assert max(shares) - min(shares) <= 1
    assert len(shares) == people

A test strategy

A test strategy answers four questions for a project: what to test, at which level, how much, and when the tests run. For Ridgeline, Jordan and Sam wrote theirs on one page.

What to test first is decided by risk: the cost of a failure multiplied by its likelihood. Money calculations, stock counts and anything customers see are high risk; a help message is low. Recently changed code and code with a history of bugs deserve extra attention. At which level follows the pyramid from lesson 1: rules as fast unit tests, the fit between modules as a smaller number of integration tests with real files and a fake shop, and a handful of end-to-end tests that drive the command-line tool through a whole day's script. How much is guided by coverage reports, branch coverage on, used to find gaps, plus boundary tests for every rule and a regression test for every bug. When is: unit tests on every save in the editor, the whole suite before every push, everything plus linting, type checking and coverage in continuous integration on every pull request.

The strategy also covers keeping the suite healthy. Slow tests get run less, so the suite should stay fast; Ridgeline's 140 tests finish in under two seconds, and any test that takes more than a fraction of a second is examined. Flaky tests are fixed or removed the day they are noticed, because a suite people do not trust is a suite people ignore. And tests are refactored like any other code when they become hard to read.

Worked problem

Planning tests for the shipping rule

Shipping is 1295 cents below 2500, 795 from 2500 to 4999 and free from 5000, plus 1000 for express, and an order of 0 or less is an error. Choose a compact set of tests.

Show solution
  1. Partitions: three bands, each with and without express, plus invalid orders.
  2. Boundaries: 2499, 2500, 4999 and 5000, each without express.
  3. Errors: 0 and -500 raise ValueError.
  4. Express: one order per band with express, checking the 1000 is added.
  5. Write it as a subTest table of about 11 rows and check branch coverage reports nothing missing.

Answer: About eleven rows, chosen from partitions, boundaries and error cases, give full branch coverage and would kill the common mutants.

Try it yourself

Cover every path

The setup defines fee(subtotal, express): a subtotal of 0 or less raises ValueError; otherwise shipping is free from 5000 cents and 795 below, plus 1000 for express. Build a list CASES of (subtotal, express, expected) rows whose expected values are correct and which together exercise all five paths: an empty order (use ValueError as the expected value), a paid order, a free order, a paid express order and a free express order.

CASES = [
    (3000, False, 795),
    (6000, False, 0),
]

Try it yourself

Kill the mutants

Write test_shipping(f) that returns True when the function f behaves like the correct shipping rule: 1295 cents below 2500, 795 from 2500 to 4999, 0 from 5000. The checker gives it the correct function, which must pass, and four mutants with shifted boundaries or a wrong price, which must all fail.

def test_shipping(f):
    return f(1000) == 1295 and f(3000) == 795 and f(9000) == 0

Try it yourself

Build a coverage tracer

Write lines_run(func, *calls) that calls func(*args) for each tuple in calls and returns the sorted list of line numbers of func that executed, counted relative to its def line (the first line of the body is 1). Use sys.settrace and always switch tracing off afterwards, even if a call raises.

import sys

def lines_run(func, *calls):
    for args in calls:
        func(*args)
    return []

Common mistakes

  • MistakeTreating a high coverage number as proof of good tests.

    InsteadCoverage shows what ran, not what was checked; read the tests and use mutation testing.

  • MistakeSetting a coverage target and rewarding people for reaching it.

    InsteadUse coverage to find gaps; keep a modest floor in CI to stop slipping.

  • MistakeMeasuring only line coverage.

    InsteadTurn on branch coverage so untested directions of decisions show up.

  • MistakeChoosing test inputs at random or only from ordinary values.

    InsteadUse partitions, boundaries and error guessing.

  • MistakeTesting everything equally.

    InsteadTest by risk: money, stock and customer-facing code first and most.

  • MistakeLiving with slow or flaky tests.

    InsteadKeep the suite fast and fix or remove flaky tests immediately.

Summary and key terms

  • Code coverage measures which code runs during the tests; coverage.py and pytest-cov report it per file with missing lines.
  • Branch coverage also tracks each direction of every decision and finds gaps line coverage misses.
  • Coverage shows what was not tested, never how well covered code was checked; targets invite empty tests.
  • Mutation testing makes small deliberate changes and checks that the tests notice them.
  • Choose inputs by equivalence partitioning, boundary value analysis and error guessing; property-based testing generates many inputs.
  • A test strategy decides what to test by risk, at which level by the pyramid, how much by coverage and boundaries, and when by automation.
  • Keep the suite fast, trustworthy and readable.
code coverage
A measure of which parts of the code run while the tests run.
line coverage
The share of statements executed by the tests.
branch coverage
The share of possible directions of decisions taken by the tests.
mutation testing
Changing code in small ways and checking that the tests fail for each change.
mutant
A copy of the code with one small deliberate change.
equivalence partitioning
Dividing inputs into groups the code treats alike and testing one from each.
boundary value analysis
Testing at and around the edges between input groups.
property-based testing
Testing that properties hold for many automatically generated inputs.
test strategy
A plan for what to test, at which level, how much and when.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. A file shows 100 line coverage. Can it still contain an untested bug? Give an example.

    Show answer

    Yes: a test might run a line without asserting its result, or never take the false branch of an if.

  2. Which coverage.py option enables branch coverage?

    Show answer

    --branch (for example coverage run --branch -m pytest).

  3. What does it mean when a mutant survives?

    Show answer

    No test noticed the change, so that behavior is not really checked.

  4. List the boundary values for a rule with bands below 2500, 2500 to 4999 and 5000 up.

    Show answer

    2499, 2500, 4999 and 5000.

  5. Why might a coverage target of 95 make tests worse?

    Show answer

    People may write tests that execute code without checking it, just to raise the number.

  6. Which Ridgeline code should be tested first, and why?

    Show answer

    Money and stock calculations, because failures there are costly and customers see them.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz