Advanced · Lesson 67 of 94

Testing strategy and coverage

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

You know how to write tests. The harder skill is choosing which ones. The rule of thumb: test behavior that would embarrass you if it broke, at the level where it is cheapest to check. Pure functions with interesting logic, boundaries and edge cases, error paths, and anything that has broken before come first. Trivial getters, framework glue, and code that only calls a well-tested library come last or not at all. A test suite is an investment, and every test costs maintenance; spend where the risk is.

Testing strategy and coverage: many unit tests: pure functions, fast, precise → fewer integration tests: modules together, real files or DB → a few end-to-end tests: the real command or API, slow, broad
Testing strategy and coverage · Figure 1 · Open for full size
Test this Skip or defer this
logic with branches, boundaries, off-by-ones one-line pass-throughs to a library
every error path you deliberately raise code the type checker already guarantees
bugs you have fixed (a regression test each) private helpers, through their public callers instead
the public API of each module exact log message wording, unless it is a contract
anything involving money, dates, permissions UI layout details

Tests are documentation when they are arranged well. Group them by the behavior or function they describe, name each test as a sentence about what should happen, test_discount_rejects_negative_percent, and keep the arrange-act-assert shape so the reader sees setup, the one call, and the check. A new contributor should be able to read test_pricing.py and know what pricing.py promises without opening it. Parametrized tables are the best form for "these inputs give these outputs"; separate tests are best for "this situation behaves this way".

Coverage measures which lines and branches your tests executed. coverage run -m pytest records it and coverage report prints the percentage per file with the missing line numbers; --branch adds whether each if went both ways. Its value is the list of missing lines: each is a branch no test reaches, and reading that list finds real gaps, such as an except block nobody triggers. Its trap is the number. A file can be 100% covered by tests that assert nothing meaningful, and chasing the percentage produces tests that exercise code without checking it. Aim for high coverage of the logic that matters, and read the missing lines rather than the total.

Test-first is a loop, not a doctrine. Write one failing test that states the next small behavior, run it and watch it fail for the right reason, write the least code that passes, run again, tidy. The rhythm keeps each step small and makes every line of code justified by a test. You do not have to do it always; do it when the behavior is clear and the implementation is not, which is most of the interesting cases.

Testing strategy and coverage: write a failing test → run: fails for the right reason → write the least code to pass → run: passes → tidy the code, tests still pass
Testing strategy and coverage · Figure 2 · Open for full size

Code

A module with a deliberate gap: parse_amount has an error branch and a currency branch. The tests cover the happy path only, and coverage shows exactly what is missing. coverage is installed for this block alone; it is not one of the course's pinned tools, which is why the version is not fixed.

# money/__init__.py
# money/parse.py
def parse_amount(text):
    """Parse '12.50', '€12.50', '12,50', or '1,234.5' into a float of euros."""
    cleaned = text.strip()
    if not cleaned:
        raise ValueError("empty amount")
    if cleaned.startswith("€"):
        cleaned = cleaned[1:]
    if "," in cleaned and "." not in cleaned:
        cleaned = cleaned.replace(",", ".")
    elif "," in cleaned:
        cleaned = cleaned.replace(",", "")
    value = float(cleaned)
    if value < 0:
        raise ValueError("amount must not be negative")
    return round(value, 2)
# tests/test_parse.py
from money.parse import parse_amount


def test_plain_number():
    assert parse_amount("12.50") == 12.5


def test_strips_whitespace():
    assert parse_amount("  3 ") == 3.0
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
# coverage_before.sh
coverage run --branch -m pytest -q 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
coverage report -m --include="money/*"
Output
..                                                                       [100%]
2 passed in Ns
Name                Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------
money/__init__.py       0      0      0      0   100%
money/parse.py         14      5     10      5    58%   5, 7, 9, 11, 14
---------------------------------------------------------------
TOTAL                  14      5     10      5    58%

Reading the missing lines gives the tests to write: the empty case, the euro sign, the comma decimal, and the negative value. Each missing branch becomes one row in a parametrized table or one raises test.

# tests/test_parse_full.py
import pytest

from money.parse import parse_amount


@pytest.mark.parametrize(
    "text,expected",
    [
        ("12.50", 12.5),
        ("  3 ", 3.0),
        ("€12.50", 12.5),
        ("12,50", 12.5),
        ("1,234.5", 1234.5),
        ("0", 0.0),
    ],
)
def test_parses_supported_formats(text, expected):
    assert parse_amount(text) == expected


@pytest.mark.parametrize("bad", ["", "   ", "-5", "€-1"])
def test_rejects_bad_input(bad):
    with pytest.raises(ValueError):
        parse_amount(bad)
# coverage_after.sh
coverage run --branch -m pytest -q 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
coverage report -m --include="money/*" | tail -2
Output
............                                                             [100%]
12 passed in Ns
---------------------------------------------------------------
TOTAL                  14      0     10      0   100%

A test-first loop for a new function, shown as three runs: the failing test, the minimal implementation, and the passing run. The behavior is specified before a line of implementation exists.

# tests/test_split.py
import pytest

from money.split import split_evenly


def test_splits_evenly():
    assert split_evenly(9.0, 3) == [3.0, 3.0, 3.0]


def test_distributes_remainder_cents():
    assert split_evenly(10.0, 3) == [3.34, 3.33, 3.33]


def test_rejects_zero_people():
    with pytest.raises(ValueError):
        split_evenly(10.0, 0)
# red.sh
pytest -q tests/test_split.py 2>&1 | grep -E "ModuleNotFoundError|error|passed|failed" | head -2
Output
E   ModuleNotFoundError: No module named 'money.split'
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
# money/split.py
def split_evenly(total, people):
    if people <= 0:
        raise ValueError("people must be positive")
    cents = round(total * 100)
    base, extra = divmod(cents, people)
    return [(base + (1 if i < extra else 0)) / 100 for i in range(people)]
# green.sh
pytest -q tests/test_split.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
...                                                                      [100%]
3 passed in Ns

Try it yourself

Take the textkit package you have been testing. Run coverage with --branch, read the missing lines, and write exactly the tests that cover them, checking each one asserts something meaningful. Then add a new function slugify(title) test-first: write three tests describing lowercase conversion, space-to-dash, and stripping punctuation, watch them fail, implement, and watch them pass. Note which of the two approaches produced clearer tests.

Common mistakes

What's next

You can decide what to test and prove you did. Next you will find out why a test fails when the reason is not obvious: debugging with breakpoint() and pdb.

Course overview