What you'll learn
- Decide what deserves a test and what does not
- Arrange tests so the file reads as a specification of the code
- Measure coverage, use it to find untested branches, and understand why 100% is not the goal
- Run a short test-first loop for a new function
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.

| 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.

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/*"
.. [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
............ [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
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/'
... [100%]
3 passed in NsTry 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
- Chasing the coverage number. A covered line with no assertion proves nothing; read the missing lines for gaps and judge tests by what they assert.
- Testing private helpers directly. Their names and shapes change; test them through the public function they serve.
- Skipping the red step. If a new test passes before you write the code, it is not testing what you think.
What's next
- Test behavior that matters most, at the cheapest level: logic, boundaries, error paths, past bugs; skip trivial glue.
- Arrange tests as a readable specification, with sentence-like names and arrange-act-assert.
- Coverage's missing lines find gaps; its percentage is not a goal. Red, green, refactor keeps steps small.
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.