◈ python mapVol 2 · Ch 24/30
Volume 2 Python, from the metal up · chapter 24

24Testing — what green actually proves

In Chapter 23 we watched the type checker judge our code without ever running it — static hints, settled before line 1 executes. In this one we actually run the thing and ask a harder question: does it do the right thing? Picture the moment. You changed one function, and your stomach dropped. Did you just break the three callers you forgot existed? A test is the machine that answers that, without you re-checking by hand. Here's the plan. We take pytest apart to the metal: how it finds your claims by walking the filesystem, how it rewrites assert at the syntax-tree level so a failure explains itself, how fixtures inject dependencies, and how parametrize multiplies one body into many. And the whole way through we keep asking the one thing that matters — when the bar goes green, what has that green really proven? By the end you can pin a claim like total([200,245]) is 445 to your code and have a machine re-check it forever. You'll know exactly, and honestly, what that green does and doesn't buy you.

iolinked · chapter 24 — the checkpoints7 steps
$ sections covered in Testing — what green actually proves
01A test is an executable claim
02Collection: discovery by convention
03The assert rewrite makes failures talk
04Fixtures are dependency injection
05Parametrize: one body, many cases
06What green proves — and what it doesn't
07Red-green-refactor and the regression test

01A test is an executable claim

Let's start with the fear, because the fear is the whole reason testing exists. You changed total() to handle a new edge case, and now you're afraid. Somewhere in the codebase three functions call it, and you can't remember which. So you do what everyone does the first year. You run the program by hand, type a few inputs, squint at the output, and ship on the fact that nothing looked wrong. That works for a weekend. It does not survive a month. The number of things you'd have to re-check by hand grows faster than any afternoon, so in practice you check almost nothing and call it confidence. The felt need is brutally simple: write down, once, a sentence like total([200,245]) is 445, and have a machine re-check that sentence forever. A test is not a chore bolted onto real work. It is the only way to keep a claim about your code alive after the moment you first believed it.

Now throw the word 'test' away and look at the primitive underneath it: assert. Watch what it actually is — not a function, but a statement the compiler itself knows about, and it compiles down to something you could have written by hand. assert x == 5 becomes, in bytecode, almost exactly if not (x == 5): raise AssertionError. Watch the machine do it:

disassemble_assert.pypython
import dis
dis.dis(compile("assert x == 5", "<s>", "exec"))

# LOAD_NAME            x
# LOAD_CONST           5
# COMPARE_OP           ==
# POP_JUMP_IF_TRUE     --> end     # true? jump clean over the raise
# LOAD_ASSERTION_ERROR             # false? build the exception
# RAISE_VARARGS        1           # and throw it
# end: LOAD_CONST None / RETURN_VALUE

Read those six lines and the whole mystery of testing evaporates. An assert evaluates an expression. If it is truthy, control jumps clean over the raise and the function keeps going, but if it is falsy, it constructs an AssertionError and throws. Two exact truths ride along. First, assert takes a second operand: assert expr, msg. The message becomes the exception's argument, so a failure can carry a sentence. Second, and this is the one that decides where you're allowed to use it: asserts are compiled entirely out when Python runs with -O. The name __debug__ is a compile-time constant, true normally and false under -O, so under optimization assert emits zero bytecode. That is why production code must never validate untrusted input with assert. Flip on -O and your check simply isn't there. But a test is debug-time-only code by definition. Assert is not merely allowed there, it is exactly right.

So here is the definition the rest of the chapter builds on. A test is just a function whose body makes claims with assert. It takes no arguments you supply by hand. It returns None on success, and raises on failure. 'The test passed' means, with no magic whatsoever, the function returned without raising. pytest does not inspect your soul. It calls your function inside a try/except. If the call returns, it paints the dot green. If any exception propagates out — an AssertionError you wrote, or a TypeError you didn't expect — it catches it and paints the dot red.

is_it_magic.pypython
def total(items):
    return sum(items)

def test_total():
    assert total([200, 245]) == 445

# pytest adds nothing but the calling loop — you can BE pytest:
try:
    test_total()
    print("green")          # returned → passed
except AssertionError as e:
    print("red", repr(e))  # raised → failed

That try/except around a function call is the entire runner in miniature. Everything downstream is a refinement of it. Collection is the question 'which functions do I call?'. The assert rewrite is 'how do I make the raised exception informative?'. Fixtures are 'what do I pass in as arguments?'. Hold the exception-as-signal model in your head and none of pytest will ever feel like sorcery again.

A test is just a function — passing means it did not raisedef test_total(): t = sum(cart) assert t == 445 # falls off end # -> returns Nonepytestcalls ittry: test_total()except AssertionError: capture(msg)returns NonePASSraises, msg savedFAILwhat `assert` really compiles toassert t == 445compiler rewritesif not (t == 445): raise AssertionErrorpython -Ostrips it — gone
Fig — A test passes by returning without raising; assert is sugar for an if not …: raise that disappears under -O, so keep asserts side-effect-free.
THE ONE IDEA TO CARRY FORWARD
A test is a function that makes claims with assert and either returns (green) or raises (red). The runner is a try/except around a call — nothing more. Everything else in this chapter is machinery bolted onto that single, unmagical fact.
Wait — if a test 'passes' merely by returning, then an empty function def test_nothing(): pass passes too — it returns None without raising. pytest will paint it green with a straight face. Your first hint that green measures 'did not raise', not 'checked something real' — a thread we pull hard in section 6.

You can write a claim. But you never told pytest your ten functions exist — no registry, no list, no main(). So how does it find them? Next: collection, the search pytest runs against your filesystem. →

02Collection: discovery by convention

You wrote ten test functions across four files. You never wired them together. There is no list of tests, no registry, no main() that calls them in order. Yet you type pytest and it finds all ten and runs them. A program you never told about your functions somehow knows they exist. And when a colleague insists the file be named test_orders.py and the function test_ships, it feels like superstition — some etiquette the tool enforces for no reason. The need here is to see that the naming rules are not etiquette. They are the literal query pytest runs against your disk and your module objects. Once you see collection as a search with rules, the worst failure in testing is a test that silently never runs. It stops being a mystery you shrug at and becomes something you can predict exactly.

Collection is a two-stage reflection process, and both stages are pure convention matched against real runtime objects. Stage one is the filesystem. pytest walks the directory tree from the rootdir (respecting testpaths if you set it) and selects files whose names match test_*.py or *_test.py. A file called helpers.py is invisible to this walk no matter how many asserts it contains. Stage two is import plus introspection. For each matched file, pytest imports it as a module — and this is the load-bearing detail of the entire section: importing a module runs it top to bottom. Every line at module level executes during collection, before a single test runs. That is why an import error, or a top-level line that raises, surfaces as a collection error — pytest never got far enough to call any test, because it crashed while reading the file.

collect_by_hand.pypython
import re, importlib

# stage one: filesystem — keep only files matching the convention
candidates = ["test_orders.py", "helpers.py", "cart_test.py"]
matched = [f for f in candidates
           if re.match(r"test_.*\.py$|.*_test\.py$", f)]
# ['test_orders.py', 'cart_test.py']  — helpers.py is simply not seen

# stage two: import each (RUNS it), then walk its namespace
module = importlib.import_module("test_orders")
items = [obj for name, obj in vars(module).items()
         if name.startswith("test") and callable(obj)]

After the import, pytest walks the module's namespace with the same machinery as dir() and vars() — it introspects the live module object. It plucks out functions whose name starts with test, and classes named Test* (with no __init__) whose methods start with test. Each match becomes a Test item: an object pytest will later call. This is why a mis-named function vanishes without a word. def check_total() is a perfectly good function that asserts perfectly good things — and pytest walks right past it, because check does not start with test. No error, no warning. It simply never runs, and your green bar is green over a claim that was never checked.

SYNTAX · the shape of a test — install it, name it, run itpytest is not in the standard library, and the names are the wiring: the filename is the query, the prefix is the filter
python -m pip install pytest -- NOT bundled with CPython. one venv, like ch 22's rich. python -m pytest -- THIS interpreter's pytest. the form that is never wrong. myproject/ tracks.py -- the code under test tests/ test_tracks.py -- matches test_*.py -> COLLECTED helpers.py -- matches nothing -> invisible, forever conftest.py -- fixtures, auto-found. you never import it. -- one test: ARRANGE, ACT, ASSERT. one behaviour, one function. def test_total_sums_the_library(): shelf = LIBRARY -- ARRANGE: build the world this claim needs seconds = total(shelf) -- ACT: one call, the thing under test assert seconds == 785 -- ASSERT: one claim about what came back def check_total(): ... -- silently never runs. no error, no warning. python -m pytest -q -- one dot per test python -m pytest -v -- one line per test, with its id python -m pytest -x -- stop at the first red python -m pytest -k "clock" -- only ids containing this substring python -m pytest --collect-only -- what WOULD run. the honesty command.
pip install pytestCPython ships unittest and never pytest. It is a package you install into one venv, exactly like Chapter 22's rich.
python -m pytestChapter 21's -m, doing two jobs: it runs this interpreter's pytest, and it puts your working directory on sys.path so from tracks import ... resolves.
test_*.pyThe filename is the query. helpers.py is not skipped or ignored — it is never opened.
def test_*The introspection filter from collection. check_total does not fail and does not warn; it is simply never plucked out of the namespace.
one behaviour per testThe function name is the failure report. Two claims in one body give you a red that names neither, and the second claim never even runs.
arrange · act · assertNot a rule, a shape. Build the world, make one call, pin one result. Setup that isn't arrange belongs in a fixture.
conftest.pyThe fixture drop-box for a directory tree. Found by the same convention search that finds tests, so you never import it and never register anything.
the exit code0 clean, 1 when something is red. That single number is how CI turns a suite into a gate.
you type
# ---------- myproject/tracks.py ----------
"""Duration helpers for the jukebox."""

LIBRARY = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)]


def as_clock(seconds):
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def total(tracks):
    return sum(secs for _title, secs in tracks)


# ---------- myproject/tests/test_tracks.py ----------
from tracks import LIBRARY, as_clock, total


def test_total_sums_the_library():
    shelf   = LIBRARY               # ARRANGE
    seconds = total(shelf)          # ACT
    assert seconds == 785           # ASSERT


def test_as_clock_pads_the_seconds():
    assert as_clock(245) == "4:05"


def check_total():                  # not 'test' -- pytest never sees it
    assert total([]) == 999


# ---------- myproject/tests/helpers.py ----------
assert 1 == 2      # never runs: helpers.py does not match test_*.py


# ---------- the terminal, in the Ch 22 venv, standing in myproject/ ----------
$ python -m pip install pytest
$ python -m pytest --version
$ python -m pytest
$ python -m pytest --collect-only -q
$ python -m pytest -q -k "clock"
$ echo $?
$ pytest                              # the console script. NOT the same command.
you see
$ python -m pip install pytest
Collecting pytest
  Downloading pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
Collecting colorama>=0.4 (from pytest)
Collecting iniconfig>=1.0.1 (from pytest)
Collecting packaging>=22 (from pytest)
Collecting pluggy<2,>=1.5 (from pytest)
Collecting pygments>=2.7.2 (from pytest)
Installing collected packages: pygments, pluggy, packaging, iniconfig,
    colorama, pytest
Successfully installed colorama-0.4.6 iniconfig-2.3.0 packaging-26.3
    pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1

$ python -m pytest --version
pytest 9.1.1

$ python -m pytest
============================= test session starts =============================
platform win32 -- Python 3.12.7, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\tmp\ch24lang\d_anat
collected 2 items

tests\test_tracks.py ..                                                  [100%]

============================== 2 passed in 0.00s ==============================

$ python -m pytest --collect-only -q
tests/test_tracks.py::test_total_sums_the_library
tests/test_tracks.py::test_as_clock_pads_the_seconds

2 tests collected in 0.00s

$ python -m pytest -q -k "clock"
.                                                                        [100%]
1 passed, 1 deselected in 0.00s

$ echo $?
0

$ pytest
=================================== ERRORS ====================================
____________________ ERROR collecting tests/test_tracks.py ____________________
ImportError while importing test module '...\d_anat\tests\test_tracks.py'.
Traceback:
tests\test_tracks.py:1: in <module>
    from tracks import LIBRARY, as_clock, total
E   ModuleNotFoundError: No module named 'tracks'
where beginners trip
  • Three test-shaped functions written, collected 2 items reported. check_total is not an error, it is silence — the worst failure mode in testing.
  • helpers.py holds assert 1 == 2 and never fires once. The filename is the filter; its contents are never read.
  • Bare pytest and python -m pytest are not the same command. Only the -m form adds your working directory to sys.path, which is the whole reason the last run above dies.
  • A file named test_utils.py in two folders collides under the default import mode. Add __init__.py files, or set importmode=importlib.
  • Code at module level in a test file runs during collection, before any test. An exception there is a collection error and zero tests run.
  • no tests ran means the query matched nothing. Check the filename first, the test_ prefix second, and your working directory third.
Collection: from files on disk to a queue of isolated itemstests/ test_prices.py test_cart.py helpers.py conftest.pymatch test_*.pyimport each matched fileruns top-to-bottom, side effects firescan namespacepluck names starting test_-> ordered list of itemsitemsrun loop — one sandbox per itemtest_pricestest_cart[a]test_cart[b]test_totaltry / excepttry / excepttry / excepttry / exceptone crash never stops the rest — dots accumulate independently
Fig — Collection walks the tree, imports each test_*.py, scans it for test_ functions, then runs each collected item in its own sandbox so one failure does not abort the others.

There is one subtlety worth meeting precisely, because it bites everyone once. In pytest's default 'prepend' import mode, it inserts each test file's directory onto sys.path and imports the file by its bare basename. So two files both named test_utils.py in different folders collide. Python's import system sees the same module name twice, and one shadows the other, producing a baffling error. The fixes are exactly what the mechanism predicts. Add __init__.py files so the packages give the modules distinct dotted names. Or switch to importmode=importlib, which imports by full path and never touches sys.path.

Now the payoff that ties collection to section 1. Each collected item is run as its own function call inside its own try/except. When item three raises an AssertionError, that exception unwinds item three's stack and is caught by the runner. The loop simply moves to item four. One test's failure cannot poison the next, because they were never on the same stack. Contrast a hand-rolled main() that calls your tests in sequence. There, the first uncaught exception kills every test after it, and you learn about one failure while five real ones hide behind it. Isolation is not a mystical property of tests. It is a structural gift pytest hands you by choosing to run N independent calls instead of one long function.

✗ The myth

"Tests are isolated because pytest resets everything between them."

✓ The reality

Isolation comes from structure, not cleanup. Each test is a separate call in a separate try/except, so one raise unwinds only that call's stack. pytest doesn't scrub state you left in a global — that leaks between tests. It only guarantees exceptions don't cross the call boundary.

pytest can find your claim and run it in isolation. But when assert response.status == 200 fails, plain Python tells you only 'AssertionError' — not the value it saw. Next: the syntax-tree surgery that makes failures talk. →

03The assert rewrite makes failures talk

You wrote assert response.status == expected. It fails. Plain Python hands you exactly one bit of information: AssertionError. Not the value of status, not the value of expected, nothing you can act on. This single poverty is why an entire vocabulary exists. There's assertEqual(a, b), assertGreater, assertIn, assertAlmostEqual — a zoo of methods you memorize purely so the failure message will finally show you the two numbers. The need is to get the informative failure without the ceremony. And pytest's answer is genuinely startling the first time you see it. A bare assert a == b fails with assert 404 == 200 — both operands, printed. That is not normal Python. It cannot be, because you saw normal Python print nothing but the exception's name. Understanding the mechanism behind it dissolves the entire assertEqual family at once, and explains why the magic works only inside pytest.

The trick is compile-time surgery on the syntax tree, and it happens at import, not at call. Here is the exact pipeline. Before Python source becomes bytecode, it is parsed into an Abstract Syntax Tree. That's a tree of nodes where assert a == b is an Assert node wrapping a Compare node. pytest registers an import hook, a finder on sys.meta_path. So when a module pytest wants to collect is imported, pytest intercepts the import, reads the source, parses it to an AST, and runs a transformer over it. That transformer rewrites every Assert node into something much larger.

what_the_rewrite_produces.pypython
# you wrote:
assert get_status() == 200

# pytest rewrites it to roughly this, at import time:
_left  = get_status()          # evaluate each operand ONCE, into a temp
_right = 200
_ok    = _left == _right
if not _ok:
    raise AssertionError(
        f"assert {_left!r} == {_right!r}")   # reprs of the saved values

Read what that buys. The operands are saved into hidden temporaries, so each subexpression is evaluated exactly once. Even get_status(), which might have a side effect, is not called twice. And because their runtime values are sitting in _left and _right, the explanation can repr() them into a message that shows you precisely what pytest saw. The transformed AST is compiled to a .pyc cached under __pycache__ with a pytest-specific tag. So this rewrite cost is paid once per file, not once per run.

SYNTAX · assert — one keyword, and the failure prints your valuesthe rewrite reprs both operands, which is exactly why ==, <, in and is retire the whole assertEqual zoo
assert expr -- Type 1: the entire vocabulary. one keyword. assert expr, "why this matters" -- Type 2: your sentence rides in the exception -- the shapes you will actually type, and what a failure prints: assert got == want -- assert 404 == 200 + where 404 = status() assert got != want assert got in shelf -- assert 'Wake Me Up' in {'Blinding Lights': 200, ...} assert got is None assert isinstance(got, int) -- the WEAKEST claim in the language. see section 6. -- floats: never with == assert value == pytest.approx(0.3) -- rel=1e-6 by default assert value == pytest.approx(4.05, abs=0.01) -- what you never type again, now that you are in pytest: self.assertEqual(a, b) self.assertGreater(a, b) self.assertIn(x, xs)
assertA statement, not a function — no parentheses around the expression. assert (a == b, "msg") is a two-item tuple, always truthy, and can never fail.
the rewriteFires only for files pytest imported through its own hook. The identical assert in a plain helper module gives you a bare AssertionError with nothing in it.
where 404 = status()The rewrite reprs sub-expressions too, so the message tells you not just the value but which call produced it.
the type-aware diffLists report At index 2 diff:, dicts report Differing items:, strings get a character-level -/+ diff. All driven by the operands' runtime types.
assert expr, msgYour sentence does not replace the introspection — pytest prints both, your message first and assert 2 == 445 underneath it.
pytest.approx0.1 + 0.2 == 0.3 is False in binary floating point, and always was. approx compares with a tolerance instead of a bit pattern.
-v / -vvLong diffs are truncated with Use -v to get more diff. -vv prints the whole thing, which is what you want the moment a dict is involved.
you type
# ---------- test_talk.py ----------
STOCK = {"Blinding Lights": 200, "Titanium": 245, "Levels": 340}


def status():
    return 404


def test_int():
    assert status() == 200


def test_str():
    assert "Titanuim" == "Titanium"


def test_list():
    assert [200, 245, 300] == [200, 245, 301]


def test_dict():
    assert STOCK == {"Blinding Lights": 200, "Titanium": 245, "Levels": 341}


def test_in():
    assert "Wake Me Up" in STOCK


# ---------- plain.py -- the same claim, outside pytest ----------
def status():
    return 404

try:
    assert status() == 200
except AssertionError as e:
    print("plain python  :", repr(e))


# ---------- the terminal ----------
$ python plain.py
$ python -m pytest -q
you see
$ python plain.py
plain python  : AssertionError()

$ python -m pytest -q
FFFFF                                                                    [100%]
================================== FAILURES ===================================
__________________________________ test_int ___________________________________

    def test_int():
>       assert status() == 200
E       assert 404 == 200
E        +  where 404 = status()

test_talk.py:9: AssertionError
__________________________________ test_str ___________________________________

    def test_str():
>       assert "Titanuim" == "Titanium"
E       AssertionError: assert 'Titanuim' == 'Titanium'
E
E         - Titanium
E         ?       -
E         + Titanuim
E         ?      +

test_talk.py:13: AssertionError
__________________________________ test_list __________________________________

    def test_list():
>       assert [200, 245, 300] == [200, 245, 301]
E       assert [200, 245, 300] == [200, 245, 301]
E
E         At index 2 diff: 300 != 301
E         Use -v to get more diff

test_talk.py:17: AssertionError
__________________________________ test_dict __________________________________

    def test_dict():
>       assert STOCK == {"Blinding Lights": 200, "Titanium": 245, "Levels": 341}
E       AssertionError: assert {'Blinding Li...'Levels': 340} == {'Blinding Li...'Levels': 341}
E
E         Omitting 2 identical items, use -vv to show
E         Differing items:
E         {'Levels': 340} != {'Levels': 341}
E         Use -v to get more diff

test_talk.py:21: AssertionError
___________________________________ test_in ___________________________________

    def test_in():
>       assert "Wake Me Up" in STOCK
E       AssertionError: assert 'Wake Me Up' in {'Blinding Lights': 200, 'Titanium': 245, 'Levels': 340}

test_talk.py:25: AssertionError
5 failed in 0.03s
where beginners trip
  • Look at the first line of the run: plain Python printed AssertionError() with nothing inside it. Everything below it is the rewrite, and only inside files pytest imported.
  • assert (a == b, "msg") puts the parentheses in the wrong place and builds a tuple. pytest catches this exact mistake with PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
  • assert 0.1 + 0.2 == 0.3 goes red and your arithmetic is fine. Reach for pytest.approx the moment a float is on either side.
  • assert isinstance(result, int) passes happily when len() was called and sum() was meant. The type is right and the answer is wrong.
  • Several asserts in one test stop at the first red. Every claim after it is never evaluated, so one bug can hide another in the same function.
  • An assert with a side effect vanishes under python -O, which strips asserts to zero bytecode. Tests never run under -O, so keep the side effect out anyway — the habit is what protects your production code.
Assertion rewriting: a better message, injected at import timeread .py sourcerewrite ASTcompilebytecodesys.meta_path import hookoriginal ASTAssertCompare ==abrewritten ASTtmp0 = atmp1 = bif not (tmp0 == tmp1): raise AssertionError( explain(tmp0, tmp1))operands captured-> readable failurethe message you getplain pythonAssertionErrorpytestassert 404 == 200where 404 = resp.status
Fig — A meta-path import hook rewrites each assert's AST between read and compile, saving the operands so the failure reads assert 404 == 200 instead of a bare AssertionError.

Three exact consequences fall out of 'it's an import-time transform', and each one explains a real confusion. One: it only fires for files pytest imports through its hook. An assert buried in a plain helper module you import normally is not rewritten. It gives you the bare, useless AssertionError, unless you explicitly call pytest.register_assert_rewrite('mymodule') before it's imported. Two: a module already imported before pytest's hook installs won't be rewritten at all, so ordering matters. Three: the introspection is type-aware. For == on two lists, dicts, or multiline strings, pytest emits a structured diff — the first differing element, or a unified diff — driven by the operands' runtime types:

type_aware_diff.pypython
def test_playlist():
    assert [200, 245, 300] == [200, 245, 301]

# pytest's failure isn't just 'AssertionError' — it points at the culprit:
#   assert [200, 245, 300] == [200, 245, 301]
#     At index 2 diff: 300 != 301

Now land the payoff. assertEqual, assertGreater, and the rest of that zoo exist because vanilla unittest could not see inside the expression. By the time self.assertTrue(a == b) runs, the comparison has already collapsed to a lone True or False. The operands are gone. So unittest made you name the relation, assertEqual, precisely so it could receive a and b separately and print them. pytest never has that problem. It sees the whole expression tree at import time, so one operator — ==, <, in, is — plus the rewrite reconstructs a better message than any hand-written assertEqual ever managed. You get to write the plainest possible Python and receive the richest possible failure. That is not a convenience layer bolted on top. It is a smarter place to stand.

SYNTAX · pytest.raises — asserting that it BREAKSthe error path is behaviour too: with makes the raise the pass condition, and match= stops it passing for the wrong reason
with pytest.raises(PlaylistError): -- Type 1: this class must be raised add_song(shelf, "Titanium", 245) with pytest.raises(PlaylistError, match=r"already on the shelf"): add_song(shelf, "Titanium", 245) -- Type 2: re.search over str(exc) with pytest.raises(ValueError) as excinfo: -- Type 3: keep the object add_song([], "Titanium", -50) assert str(excinfo.value) == "impossible length: -50s" assert excinfo.type is ValueError assert isinstance(excinfo.value.__cause__, ValueError) -- ch 13's raise .. from -- there are exactly two ways this test goes red: Failed: DID NOT RAISE PlaylistError -- the call simply succeeded ValueError: impossible length: -1s -- a DIFFERENT class escaped -- and the claim that almost always belongs beside it: assert shelf == [("Titanium", 245)] -- it refused AND changed nothing
pytest.raises(E)A context manager. The block passes when E or a subclass escapes it, and fails when nothing escapes. The raise is the green.
match=re.search against str(exc) — a regex, not a substring. Escape (, ), . and $, or wrap the text in re.escape.
as excinfoAn ExceptionInfo wrapper, not the exception. .value is the object, .type the class, .traceback the frames.
DID NOT RAISEThe failure that means your input was not broken enough. Nine times in ten the fix is in the test's argument, not in the code.
the subclass rulepytest.raises(Exception) catches your own TypeError typo and paints it green. Name the narrowest class you actually mean.
.__cause__Chapter 13's raise ... from e chain, and it is assertable: check that the low-level failure is still attached under your translated one.
keep the block shortOne call inside the with. Anything else in there might be the thing that raised, and your test would never tell you.
you type
# ---------- tracks.py (the ch 13 guards, still unchanged) ----------
class PlaylistError(Exception):
    """Something is wrong with this playlist."""


def add_song(shelf, title, seconds):
    if seconds <= 0:
        raise ValueError(f"impossible length: {seconds}s")
    if any(t == title for t, _s in shelf):
        raise PlaylistError(f"{title!r} is already on the shelf")
    shelf.append((title, seconds))
    return shelf


# ---------- test_errors.py ----------
import pytest

from tracks import PlaylistError, add_song


def test_duplicate_raises():
    shelf = [("Titanium", 245)]
    with pytest.raises(PlaylistError):
        add_song(shelf, "Titanium", 245)


def test_message_matches():
    with pytest.raises(PlaylistError, match=r"already on the shelf"):
        add_song([("Titanium", 245)], "Titanium", 245)


def test_capture_the_object():
    with pytest.raises(ValueError) as excinfo:
        add_song([], "Titanium", -50)
    assert str(excinfo.value) == "impossible length: -50s"
    assert excinfo.type is ValueError


def test_the_shelf_is_untouched():
    shelf = [("Titanium", 245)]
    with pytest.raises(PlaylistError):
        add_song(shelf, "Titanium", 245)
    assert shelf == [("Titanium", 245)]


def test_forgot_to_break_it():
    with pytest.raises(PlaylistError):
        add_song([], "Levels", 340)          # this call SUCCEEDS


def test_wrong_class():
    with pytest.raises(PlaylistError):
        add_song([], "Levels", -1)           # raises ValueError, not PlaylistError


# ---------- the terminal ----------
$ python -m pytest -q
you see
$ python -m pytest -q
....FF                                                                   [100%]
================================== FAILURES ===================================
___________________________ test_forgot_to_break_it ___________________________

    def test_forgot_to_break_it():
>       with pytest.raises(PlaylistError):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE PlaylistError

test_errors.py:32: Failed
______________________________ test_wrong_class _______________________________

    def test_wrong_class():
        with pytest.raises(PlaylistError):
>           add_song([], "Levels", -1)           # raises ValueError, not PlaylistError
            ^^^^^^^^^^^^^^^^^^^^^^^^^^

test_errors.py:38:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

shelf = [], title = 'Levels', seconds = -1

    def add_song(shelf, title, seconds):
        if seconds <= 0:
>           raise ValueError(f"impossible length: {seconds}s")
E           ValueError: impossible length: -1s

tracks.py:25: ValueError
=========================== short test summary info ===========================
FAILED test_errors.py::test_forgot_to_break_it - Failed: DID NOT RAISE Playli...
FAILED test_errors.py::test_wrong_class - ValueError: impossible length: -1s
2 failed, 4 passed in 0.03s
where beginners trip
  • pytest.raises(ValueError) with no match goes green when a completely different ValueError fires. That is a passing test sitting on top of a bug.
  • The last two reds are the only two shapes this test has: the call did not raise, or the wrong class escaped. Read which one you got before you touch anything.
  • excinfo.value does not exist inside the block. pytest says .value can only be used after the context manager exits, because the object is attached on the way out.
  • match="cost (in cents)" fails against the message cost (in cents) is negative. The parentheses are a regex group; write r"cost \(in cents\)".
  • Only the first raising line in the block counts — everything after it never runs, so a two-call block can pass for the call you were not testing.
  • Asserting the raise is half the claim. The other half is that the refusal left your data alone, which is why assert shelf == [...] sits under two of the tests above.
DELETE THE assertEqual ZOO
Inside a pytest test, never reach for assertEqual, assertGreater, or assertIn. Write assert a == b, assert a > b, assert x in xs. The rewrite reprs the operands and, for containers, diffs them. The only time you fall back to bare-AssertionError land is an assert in a non-collected helper — and that's a signal to move the check into the test or register the module.

Failures talk now. But every test still opens the same database, builds the same temp dir, logs in the same client — ten lines of setup drowning the one line of claim. Next: fixtures, and dependency injection by name. →

04Fixtures are dependency injection

Every test in the file needs the same thing before it can start: a fresh database connection, a temp directory, a logged-in client. So the top of every test function grows the same six lines of setup and the same four lines of teardown, and now the boilerplate outweighs the claim. Worse, when the setup changes you edit it in twenty places. And if you get clever and build the object once to share it, test three mutates it and test seven fails. But only when they run in that order, which is the most maddening bug there is. The need is precise. Declare 'this test needs a fresh X', and have the machine build a clean X, hand it over, and tear it down afterward — with no boilerplate in the test body, and no test bleeding state into the next. That mechanism is dependency injection, and pytest's version of it is the feature people most misread as magic.

A fixture is a function decorated with @pytest.fixture. The wiring is pure name-matching through introspection. Before pytest calls a test, it inspects the test function's parameter names with inspect.signature. For each parameter, it looks up a fixture of that exact name, calls it, and passes the return value as that argument. That is the whole of 'injection'. The test declares a dependency by naming it as a parameter, and pytest resolves the name to a provider. Nothing is global, and the test never constructs the object itself.

injection_by_name.pypython
import pytest

@pytest.fixture
def playlist():
    return [200, 245]        # Blinding Lights, Titanium

def test_total(playlist):        # names 'playlist' → pytest injects it
    assert total(playlist) == 445

Fixtures can themselves take fixture parameters, so pytest builds a dependency graph and resolves it depth-first. A db fixture that names a config parameter forces config to be built first, its result fed into db, and db's result fed into the test. Now the two mechanisms that matter most. Setup and teardown ride on yield. A fixture that yields is a generator. pytest calls next() to run everything up to the yield — that's setup — and hands the yielded value to the test. When the test finishes, pytest calls next() again, driving the generator past the yield to StopIteration. That runs everything after the yield, which is teardown. The yield is the literal seam between the two phases. And because pytest drives that second next() inside a finally, teardown runs even if the test failed.

yield_is_the_seam.pypython
@pytest.fixture
def db():
    conn = connect()        # setup — runs before the test
    yield conn              # <- the seam: hand conn to the test
    conn.close()            # teardown — runs after, even on failure
Fixtures: parameter names are dependency requestsdef test_ship( cart, db): assert ship(cart)pytest reads the signature,resolves each name to a providercartsetup: cart = Cart([...])yield cart(handed to the test)teardown: nonescope=function — rebuilt every testdbsetup: conn = connect()yield conn(handed to the test)teardown: conn.close()scope=session — built once, reused
Fig — pytest matches each parameter to a fixture; the code before yield is setup, the yielded object is injected, and the code after yield is teardown — rerun per test or once per session by scope.

Scope controls how long the built object lives. The default, scope='function', means the fixture is re-called and re-torn-down for every single test — a fresh object each time. This is exactly why order-independence is the default. Test seven can't be poisoned by test three mutating the object, because test seven received a different object, built by a fresh call. Widen to scope='module' or 'session' and pytest calls the fixture once and memoizes the return value, reusing the same object across many tests. That is faster, since you connect to the database once, not four hundred times. But now shared mutable state is possible again, and you must understand the trade you are buying. Speed on one side, the risk of order-dependent tests on the other.

SYNTAX · @pytest.fixture — naming what a test needsthe parameter name is the request, yield is the seam, and tmp_path is a real directory pytest builds fresh for every test
@pytest.fixture -- Type 1: return -- setup only def playlist(): return [200, 245] @pytest.fixture -- Type 2: yield -- setup AND teardown def db(): conn = connect() -- setup yield conn -- the seam. the test body runs HERE. conn.close() -- teardown. runs even when the test failed. @pytest.fixture(scope="module") -- Type 3: built once, shared. read the warning. def big_index(): ... def test_total(playlist): -- the PARAMETER NAME is the whole request assert total(playlist) == 445 -- pytest's own built-ins. these already exist; never write them yourself. def test_saves(tmp_path): -- a fresh empty Path, unique per test path = tmp_path / "jukebox.json" def test_env(monkeypatch): ... -- set an env var or attribute, undone after def test_out(capsys): ... -- capture stdout and stderr conftest.py -- fixtures here are found by every test below it python -m pytest --fixtures-per-test t.py::test_x -- who provides what, and where
the parameter nameResolved with inspect.signature against the fixtures in scope. Nothing global, nothing imported, no registry — the name is the wire.
return vs yieldA yielding fixture is a generator. pytest drives it with two next() calls, and the second one lives in a finally, so teardown survives a red test.
scope="function"The default. The fixture is re-called for every test, so builds == tests. That equality is what isolation actually is.
tmp_pathA real pathlib.Path to a real directory on disk, unique per test. pytest keeps the last three runs' directories and deletes the rest.
tmp_path_factoryIts session-scoped sibling, for one expensive directory that many tests read. Same machinery, wider scope, same trade.
conftest.pyThe drop-box. Fixtures defined there are visible to every test in that directory and below, discovered by the same convention search that finds tests.
fixtures request fixturesA db fixture that names a config parameter forces config to be built first. pytest resolves the whole dependency graph depth-first.
you type
# ---------- conftest.py ----------
import pytest


@pytest.fixture
def playlist():
    print("   [setup]  building a fresh playlist")
    return [200, 245]


# ---------- test_fix.py ----------
import pytest


@pytest.fixture
def logfile(tmp_path):
    path = tmp_path / "plays.log"
    path.write_text("Blinding Lights\n", encoding="utf-8")
    yield path                              # <- the seam: the test runs here
    print(f"   [teardown] {path.name} had {len(path.read_text())} bytes")


def test_a_mutates_its_own(playlist):
    playlist.append(999)
    assert playlist == [200, 245, 999]


def test_b_sees_a_fresh_one(playlist):
    assert playlist == [200, 245]           # the 999 never happened here


def test_the_logfile_is_real(logfile, tmp_path):
    logfile.write_text("Titanium\n", encoding="utf-8")
    print("   [test]   tmp_path =", tmp_path.name)
    assert logfile.read_text(encoding="utf-8") == "Titanium\n"
    assert logfile.parent == tmp_path


def test_gets_its_own_tmp_path(tmp_path):
    print("   [test]   tmp_path =", tmp_path.name)
    assert not (tmp_path / "plays.log").exists()


# ---------- the terminal ----------
$ python -m pytest -q -s
$ python -m pytest --fixtures-per-test test_fix.py::test_the_logfile_is_real
you see
$ python -m pytest -q -s
   [setup]  building a fresh playlist
.   [setup]  building a fresh playlist
.   [test]   tmp_path = test_the_logfile_is_real0
.   [teardown] plays.log had 9 bytes
   [test]   tmp_path = test_gets_its_own_tmp_path0
.
4 passed in 0.03s

$ python -m pytest --fixtures-per-test test_fix.py::test_the_logfile_is_real
------------------ fixtures used by test_the_logfile_is_real ------------------
------------------------------ (test_fix.py:22) -------------------------------
logfile -- test_fix.py:5
    no docstring available
tmp_path -- ...\_pytest\tmpdir.py:291
    Return a temporary directory (as :class:`pathlib.Path` object)
    which is unique to each test function invocation.
tmp_path_factory -- ...\_pytest\tmpdir.py:276
    Return a :class:`pytest.TempPathFactory` instance for the session.
where beginners trip
  • Count the [setup] lines: two tests named playlist, two builds. Test A appended 999 and test B still saw [200, 245], because it received a different list.
  • A typo in the parameter name is not a NameError. It is fixture 'playlst' not found at setup time, followed by a list of every fixture that does exist — which is the fastest way to find the right spelling.
  • Calling the fixture yourself — playlist() inside the test — fails with Fixture "playlist" called directly. pytest calls it; you only name it.
  • Teardown after yield does not run if the setup raised, because the generator never reached the yield. Anything built before the crash leaks.
  • The two tmp_path names above differ per test, and one of them is empty while the other holds a file. Two tests never share a directory, so a leftover file cannot leak forward.
  • Widen to scope="session" and builds==tests becomes builds==1. You bought speed with the possibility of an order-dependent suite; make that trade on purpose.

Two final threads. First, connect fixtures to the chapter's spine: collection decides which functions to call, and fixtures decide what to pass to them. The parameter name is the whole interface between the two. Second, where do fixtures live so many files can share them? They live in conftest.py. pytest auto-discovers fixtures defined there for every test in that directory tree, with no import needed and no wiring. It is the exact same convention-over-configuration idea that powered collection. You never register a fixture any more than you register a test — you name it, put it in the right place, and the search finds it.

TYPE THIS · save it, then run ittest_the_jukebox_store.py — chapter 18's save/load, checked against a real file in a directory pytest builds for you
# ---------- jukebox_store.py -- chapter 18's store, with the run script stripped off ----------
import json
from dataclasses import dataclass, asdict
from pathlib import Path

DB = Path("jukebox.json")
VERSION = 1


@dataclass
class Track:
    title: str
    artist: str
    seconds: int
    plays: int = 0


def save(tracks, path=DB):
    """Whole library, one JSON document. Returns how many tracks landed."""
    payload = {"version": VERSION, "tracks": [asdict(t) for t in tracks]}
    with open(path, "w", encoding="utf-8", newline="\n") as f:
        json.dump(payload, f, indent=2, ensure_ascii=False)
    return len(tracks)


def load(path=DB):
    """JSON back into Track objects. A broken file is reported, never guessed at."""
    if not path.exists():
        return []
    with open(path, "r", encoding="utf-8") as f:
        try:
            payload = json.load(f)
        except json.JSONDecodeError as e:
            print(f"  {path.name} is not valid JSON: {e.msg} at char {e.pos}")
            return []
    if payload.get("version") != VERSION:
        print(f"  version {payload.get('version')} file, this code speaks {VERSION}")
    return [Track(**row) for row in payload["tracks"]]


# ---------- test_the_jukebox_store.py ----------
import json

import pytest

from jukebox_store import Track, load, save


@pytest.fixture
def library():
    """The three tracks, rebuilt fresh for every single test."""
    return [
        Track("Blinding Lights", "The Weeknd", 200, plays=1),
        Track('Sing, Sing, Sing | "Live"', "Benny Goodman", 512),
        Track("Bjork - Joga", "Bjork", 302),
    ]


@pytest.fixture
def db(tmp_path):
    """A path inside pytest's own temp dir. Nothing exists there yet."""
    return tmp_path / "jukebox.json"


def test_save_writes_the_file_and_counts(library, db):
    assert not db.exists()
    assert save(library, db) == 3
    assert db.exists()


def test_round_trip_returns_equal_tracks(library, db):
    save(library, db)
    assert load(db) == library


def test_the_delimiter_row_survives(library, db):
    save(library, db)
    assert load(db)[1].title == 'Sing, Sing, Sing | "Live"'


def test_load_of_a_missing_file_is_empty(db):
    assert load(db) == []


def test_the_file_is_readable_json_on_disk(library, db):
    save(library, db)
    payload = json.loads(db.read_text(encoding="utf-8"))
    assert payload["version"] == 1
    assert payload["tracks"][0]["plays"] == 1


def test_each_test_gets_its_own_directory(db):
    save([Track("Levels", "Avicii", 340)], db)
    assert len(load(db)) == 1
$ python -m pytest test_the_jukebox_store.py -v
============================= test session starts =============================
platform win32 -- Python 3.12.7, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\tmp\ch24lang\lab
collecting ... collected 6 items

test_the_jukebox_store.py::test_save_writes_the_file_and_counts PASSED   [ 16%]
test_the_jukebox_store.py::test_round_trip_returns_equal_tracks PASSED   [ 33%]
test_the_jukebox_store.py::test_the_delimiter_row_survives PASSED        [ 50%]
test_the_jukebox_store.py::test_load_of_a_missing_file_is_empty PASSED   [ 66%]
test_the_jukebox_store.py::test_the_file_is_readable_json_on_disk PASSED [ 83%]
test_the_jukebox_store.py::test_each_test_gets_its_own_directory PASSED  [100%]

============================== 6 passed in 0.07s ==============================

$ python -m pytest -q -s -k "own_directory"     # where does tmp_path actually point?
tmp_path = C:\Users\ajair\AppData\Local\Temp\pytest-of-ajair\pytest-2\test_show0
.
1 passed in 0.01s
Save both files side by side and run it. Six tests, six passes, and not one of them touched your working directory. Look at the path in the second run. That is tmp_path: a real directory on a real disk, made fresh for each test, with a name built from the test's own id. Real files, real open(), real bytes; just not your files. Now read what the two fixtures are doing, because they are doing different jobs. library rebuilds three Track objects per test, so test_save_writes_the_file_and_counts cannot hand a mutated list to test_round_trip_returns_equal_tracks. db takes tmp_path as its own parameter, a fixture requesting a fixture, and returns one path inside it. That is the dependency graph from the last section, three nodes deep: tmp_path feeds db, and db feeds the test. The claim doing the heaviest lifting is assert load(db) == library. It compares a list of dataclasses that came back off disk against the list that went down, and it is only that short because Chapter 12's @dataclass wrote __eq__ for us. One line, and it pins the whole round trip: the JSON was written, parsed, and rebuilt into objects that compare equal field for field — including plays=1, which is the field a careless asdict would drop. And test_the_delimiter_row_survives is Chapter 15's ghost, kept as a permanent claim. That title holds a comma, a pipe and two quote marks, the exact row that destroyed the hand-rolled CSV back then. Three things worth doing. Delete plays: int = 0 from Track and re-run: you get four errors, not four failures, each one pointing back at the library fixture with TypeError: Track.__init__() got an unexpected keyword argument 'plays'. pytest prints ERROR when the setup broke and FAILED when the claim broke, and that one word tells you which half to go read. Then bump VERSION to 2 and re-run: exactly one red, assert 2 == 1, from the only test that reads the raw bytes back off the disk. Then delete the db fixture and hard-code Path("jukebox.json") instead. Now test_load_of_a_missing_file_is_empty goes red, because the file is not missing any more — an earlier test wrote it — and a stray jukebox.json is sitting in your project directory. One small fixture was holding both of those shut.
SESSION SCOPE IS A LOADED GUN
The instant you widen a fixture to module or session scope, your tests share one mutable object, and order-independence is gone. A test that mutates it can make a later test pass or fail depending purely on run order — the bug that only reproduces on the CI server. Reach for wide scope only for genuinely read-only or expensive-and-immutable resources, and keep anything a test might mutate at the default function scope.

One fixture feeds one object to one test. But you want to run the same claim over empty lists, single elements, negatives, and huge inputs — without copy-pasting the body four times. Next: parametrize, one body into many cases. →

05Parametrize: one body, many cases

You want to test total() on the empty list, one element, some negatives, and a big list. So you copy the test function four times, changing two literals in each. Now the assertion logic lives in four places. Fix a bug in how you assert and you fix it four times. And when case three fails, the traceback says test_total_negatives, and you have to go read the body to remember what input that even was. The obvious escape is a loop inside one test, for case in cases: assert .... But that's worse, because the first failing case raises and unwinds the whole function. So you learn about failure one and nothing about two through four, and the whole thing reports as a single red. The need: run one claim over a table of inputs, where each row is its own independently-reported case that runs even if its neighbors fail — without duplicating the body. That is parametrization, and it is the difference between a test and a test suite.

parametrize.pypython
import pytest

@pytest.mark.parametrize("items,expected", [
    ([],            0),
    ([200],         200),
    ([200, 245],    445),
    ([-5, 5],       0),
])
def test_total(items, expected):
    assert total(items) == expected

Here is the mechanism, and it is not what it looks like. @pytest.mark.parametrize is not a runtime loop. It acts at collection time. The mark attaches metadata to the test function. During collection — the same stage from section 2 that turned files into items — pytest reads that metadata and multiplies the single function into N distinct test items, one per row of the table, binding the named parameters to that row's values. It uses the very same parameter-injection machinery as fixtures. Parametrize is essentially a fixture whose values you supply explicitly. So the four-row table above does not produce one test that loops four times. It produces four separate items.

SYNTAX · @pytest.mark.parametrize — one body, a table of casesit acts at collection time, so N rows become N items with N ids and N verdicts — and the collected count is where you see it happen
@pytest.mark.parametrize("items,expected", [ -- the names, then the rows ([], 0), ([("Titanium", 245)], 245), ([("a", 200), ("b", 245)], 445), ]) def test_total(items, expected): -- the names become parameters assert total(items) == expected -- Type 2: label each row, so the red says something human @pytest.mark.parametrize("seconds,expected", [ pytest.param(0, "0:00", id="zero"), pytest.param(3600, "60:00", id="an-hour"), ]) -- Type 3: stack them. this is the CARTESIAN PRODUCT: 2 x 3 = 6 items. @pytest.mark.parametrize("secs", [0, 59, 245]) @pytest.mark.parametrize("prefix", ["", "~ "]) def test_stacked(prefix, secs): ... -- one parameter? bare values, no one-tuples @pytest.mark.parametrize("seconds", [0, 59, 245]) python -m pytest --collect-only -q -- every generated id, BEFORE anything runs python -m pytest -k "an-hour" -- run exactly one row
the first argumentA comma-separated string of parameter names. They must match the function's parameters exactly, and a mismatch is a collection error, not a runtime one.
the second argumentAn iterable of rows: one value per name, as a tuple when there is more than one name. Evaluated once, at import.
collection timeThe function is cloned per row into a separate item with the row's values bound. This is not a loop and there is no loop anywhere.
the generated idBuilt from the values, or taken from your pytest.param(..., id=...). It labels the failure and works as a -k selector.
collected 14 itemsFrom three def test_s. That gap between functions written and items collected is parametrize, visible in the header of every run.
stackingThe product, not the zip. Two three-row decorators give 9 items; three four-row decorators give 64. It multiplies faster than you expect.
pytest.param(..., marks=...)Attach xfail or skip to a single row — the honest way to keep a known-broken case in the table without hiding it.
you type
# ---------- test_param.py ----------
import pytest

from tracks import as_clock, total


@pytest.mark.parametrize("items,expected", [
    ([],                          0),
    ([("Titanium", 245)],       245),
    ([("a", 200), ("b", 245)],  445),
    ([("a", -5), ("b", 5)],       0),
])
def test_total(items, expected):
    assert total(items) == expected


@pytest.mark.parametrize("seconds,expected", [
    pytest.param(0,    "0:00",  id="zero"),
    pytest.param(59,   "0:59",  id="under-a-minute"),
    pytest.param(60,   "1:00",  id="exactly-a-minute"),
    pytest.param(3600, "60:00", id="an-hour"),
])
def test_as_clock(seconds, expected):
    assert as_clock(seconds) == expected


@pytest.mark.parametrize("secs",   [0, 59, 245])
@pytest.mark.parametrize("prefix", ["", "~ "])
def test_stacked(prefix, secs):
    assert (prefix + as_clock(secs)).endswith(as_clock(secs))


# ---------- the terminal ----------
$ python -m pytest --collect-only -q
$ python -m pytest -q
$ python -m pytest -q -k "an-hour"
$ python -m pytest -q "test_param.py::test_total[items2-445]"
you see
$ python -m pytest --collect-only -q
test_param.py::test_total[items0-0]
test_param.py::test_total[items1-245]
test_param.py::test_total[items2-445]
test_param.py::test_total[items3-0]
test_param.py::test_as_clock[zero]
test_param.py::test_as_clock[under-a-minute]
test_param.py::test_as_clock[exactly-a-minute]
test_param.py::test_as_clock[an-hour]
test_param.py::test_stacked[-0]
test_param.py::test_stacked[-59]
test_param.py::test_stacked[-245]
test_param.py::test_stacked[~ -0]
test_param.py::test_stacked[~ -59]
test_param.py::test_stacked[~ -245]

14 tests collected in 0.01s

$ python -m pytest -q
..............                                                           [100%]
14 passed in 0.01s

$ python -m pytest -q -k "an-hour"
.                                                                        [100%]
1 passed, 13 deselected in 0.00s

$ python -m pytest -q "test_param.py::test_total[items2-445]"
.                                                                        [100%]
1 passed in 0.00s
where beginners trip
  • Three def test_ functions in the file and 14 tests collected in the header. Nothing miscounted — the expansion happened before a single test ran.
  • A for loop over the same rows is one item. The first red ends the function, so the cases after it are never even evaluated.
  • The names in the string must match the parameters. Get one wrong and you get a collection error naming the fixture pytest went looking for.
  • Values that do not print cleanly give ids like items0. Compare the first four ids above with the four named ones underneath, then label yours with pytest.param(id=...).
  • Stacked decorators multiply. The 3 × 2 above is 6 items; four of those decorators on one function is a suite you did not mean to write.
  • A single-name parametrize takes bare values — [0, 59, 245], not [(0,), (59,)]. The one-tuple form is the most common first-day error.
Parametrize: one function, many independent cases@parametrize(items, exp)def test_total(items, exp): assert total(items)==expitems | exp[] | 0[2] | 2[2,3] | 5[9]*3 | 27collection time fans it outtest_total[[]-0]test_total[[2]-2]test_total[[2,3]-5]test_total[[9]*3-27]@parametrize — 4 independent resultsone red, three green — failure isolates, others still runantipattern: for-loop in one testfor i,e in rows: assert total(i)==e1 dot — stops at first failure, reports one resultstacked decorators multiply — 3 x 2 = 6 items (Cartesian product)
Fig — @parametrize fans one function into many collected items, each its own pass or fail; a for-loop collapses them into a single dot that dies at the first failure, and stacking decorators forms a Cartesian product.

Three consequences fall out, and every one of them is a reason parametrize beats the loop. One: each row becomes its own item with its own generated id. pytest builds an id like test_total[items3-expected3] from the values, or from your ids= labels if you give them. So the report shows four results, not one, and a failure names the exact input that broke. Two: because they are separate items, each runs in its own call inside its own try/except, so case two failing does not stop case three. You get four independent verdicts and the full picture. Three: stacking two parametrize decorators produces the Cartesian product. A 3-row and a 2-row decorator generate 3 × 2 = 6 items, one per pair, which pytest builds by nesting the multiplication.

Now feel the contrast sharply against the loop-inside-a-test. That loop is one item. The first assert that fails raises and unwinds the entire function, so cases after it never execute, and a single red hides how many cases actually broke — one? all four? You cannot tell. Parametrize inverts every part of that: N items, N independent calls, N verdicts, and each failure self-labeling its input. Same four claims; two completely different execution structures, and only one of them tells you the truth.

✗ The myth

"parametrize is just a tidy way to write a loop over test cases."

✓ The reality

A loop is one item that stops at the first failure. Parametrize runs at collection time and produces N separate items — N independent verdicts, each named by its input, each running even if its neighbors fail. That's why 'collected 6 items' can exceed the number of def test_ functions you wrote.

That last line is the tell that ties this section back to collection. When pytest prints collected 6 items from a file with two test functions, it is not miscounting — parametrize is collection-time item generation. The same discovery machine that turned files into items also expands one function into a whole family of them. Discovery and multiplication are one mechanism, seen twice.

TYPE THIS · save it, then run it, then break ittest_tracks.py — six tests over chapters 19 and 21's module, green, then one seeded bug and the failure that explains itself
# ---------- tracks.py -- ch 19's module, with ch 13's guards ----------
"""Duration helpers for the jukebox."""


class PlaylistError(Exception):
    """Something is wrong with this playlist."""


LIBRARY = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)]


def as_clock(seconds):
    """Turn a whole number of seconds into m:ss."""
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def total(tracks):
    """Sum the seconds of an iterable of (title, seconds) pairs."""
    return sum(secs for _title, secs in tracks)


def add_song(shelf, title, seconds):
    """Append (title, seconds) to shelf. Refuse junk instead of storing it."""
    if seconds <= 0:
        raise ValueError(f"impossible length: {seconds}s")
    if any(t == title for t, _s in shelf):
        raise PlaylistError(f"{title!r} is already on the shelf")
    shelf.append((title, seconds))
    return shelf


# ---------- test_tracks.py ----------
import pytest

from tracks import LIBRARY, PlaylistError, add_song, as_clock, total


def test_total_sums_the_library():
    assert total(LIBRARY) == 785


def test_total_of_nothing_is_zero():
    assert total([]) == 0


@pytest.mark.parametrize("seconds,expected", [
    (0,    "0:00"),
    (59,   "0:59"),
    (60,   "1:00"),
    (200,  "3:20"),
    (245,  "4:05"),
    (3600, "60:00"),
])
def test_as_clock_formats(seconds, expected):
    assert as_clock(seconds) == expected


def test_add_song_appends_one_row():
    shelf = [("Blinding Lights", 200)]
    add_song(shelf, "Titanium", 245)
    assert shelf == [("Blinding Lights", 200), ("Titanium", 245)]


def test_add_song_refuses_a_duplicate():
    shelf = [("Titanium", 245)]
    with pytest.raises(PlaylistError, match="already on the shelf"):
        add_song(shelf, "Titanium", 245)
    assert shelf == [("Titanium", 245)]      # and it did NOT append


def test_add_song_refuses_an_impossible_length():
    with pytest.raises(ValueError) as excinfo:
        add_song([], "Titanium", -50)
    assert "impossible length: -50s" in str(excinfo.value)


# ---------- now seed ONE bug in tracks.py and run it again ----------
    shelf.append([title, seconds])   # BUG: a list, not a tuple
$ python -m pytest -v
============================= test session starts =============================
platform win32 -- Python 3.12.7, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\tmp\ch24lang\lab
collecting ... collected 11 items

test_tracks.py::test_total_sums_the_library PASSED                       [  9%]
test_tracks.py::test_total_of_nothing_is_zero PASSED                     [ 18%]
test_tracks.py::test_as_clock_formats[0-0:00] PASSED                     [ 27%]
test_tracks.py::test_as_clock_formats[59-0:59] PASSED                    [ 36%]
test_tracks.py::test_as_clock_formats[60-1:00] PASSED                    [ 45%]
test_tracks.py::test_as_clock_formats[200-3:20] PASSED                   [ 54%]
test_tracks.py::test_as_clock_formats[245-4:05] PASSED                   [ 63%]
test_tracks.py::test_as_clock_formats[3600-60:00] PASSED                 [ 72%]
test_tracks.py::test_add_song_appends_one_row PASSED                     [ 81%]
test_tracks.py::test_add_song_refuses_a_duplicate PASSED                 [ 90%]
test_tracks.py::test_add_song_refuses_an_impossible_length PASSED        [100%]

============================= 11 passed in 0.01s ==============================

$ python -m pytest -q          # after seeding the bug
........F..                                                              [100%]
================================== FAILURES ===================================
________________________ test_add_song_appends_one_row ________________________

    def test_add_song_appends_one_row():
        shelf = [("Blinding Lights", 200)]
        add_song(shelf, "Titanium", 245)
>       assert shelf == [("Blinding Lights", 200), ("Titanium", 245)]
E       AssertionError: assert [('Blinding L...tanium', 245]] == [('Blinding L...tanium', 245)]
E
E         At index 1 diff: ['Titanium', 245] != ('Titanium', 245)
E         Use -v to get more diff

test_tracks.py:30: AssertionError
=========================== short test summary info ===========================
FAILED test_tracks.py::test_add_song_appends_one_row - AssertionError: assert...
1 failed, 10 passed in 0.05s
Save both files side by side and run the first command. Read the header before the dots: six functions written, eleven items collected. The parametrize table fanned test_as_clock_formats into six, and each id carries its own input. [245-4:05] tells you which case you are looking at without opening the file. That is the whole argument for the table over four copy-pasted functions. Now look at what the six tests actually pin down. Two claim values (785, 0). Six claim a formatting contract at the edges: zero, one under a minute, exactly a minute, an hour. That is precisely where an off-by-one lives. Two claim that the code refuses, and both of them also claim the shelf came out unchanged, because a function that raises after half-mutating your data is a worse bug than one that never raised. Then seed the bug: change shelf.append((title, seconds)) to shelf.append([title, seconds]), one bracket pair, and run again. Ten dots and one F. And read that failure, because it is the whole of section 3 arriving in one line: At index 1 diff: ['Titanium', 245] != ('Titanium', 245). Not AssertionError. Not the lists differ. It walked both sequences, found the first index that disagreed, and printed both values with their brackets intact, so you can see the culprit is a list where a tuple belongs. Nothing else in the suite moved. Three things to try. Put the tuple back, then change 785 to 786 and read how a plain integer failure reads. Delete the (0, "0:00") row and re-run — ten items, all green, one behaviour now unwatched. Then break as_clock by dropping the :02d, and count how many of the six rows go red and which four titles they carry.

Now your suite is green — 47 passed — and you feel safe. That feeling is the most dangerous thing in this chapter, because it is largely unearned. Next: exactly what a green bar licenses you to believe. →

06What green proves — and what it doesn't

The suite is green. 47 passed. You feel safe, and that feeling is the most dangerous thing in this whole chapter, because it is largely unearned. Green feels like 'my code is correct.' It is not. It means, exactly, 'the specific claims I remembered to write still hold.' The gap between those two sentences is not a technicality. It is where every shipped bug lives. This section is the intellectual core of the chapter, the epistemics of testing. Its job is to install a permanent, accurate model of what a green bar does and does not license. That way you neither over-trust it (ship on green with three shallow tests) nor over-fear it (chase 100% coverage believing the number means correctness).

Formalize it. A test suite is a finite set of claims {c₁ … cₙ}. Green means every cᵢ evaluated true on this run. It says exactly nothing about any behavior you did not express as some cᵢ. This is Dijkstra's razor made concrete: testing shows the presence of bugs, never their absence. The input space of any nontrivial function is astronomically large, often infinitely so, and your tests sample a handful of points in it. Green proves the sampled points behave. The unsampled continuum, which is essentially all of it, stays uncharted. A test that never fails on the inputs you chose tells you nothing about the input you didn't.

Green does not mean correct — two blind spotsinput space — every possible argumentyour tests (a few points)unproven space —every bug hides hereoff-by-onecoverage trap — 100% lines executeddef price(x): p = x * 1.0 # forgot tax return pwrong value,covered lineassert isinstance(p, float)checks the type, never the valuelines executed ≠ behaviours verified
Fig — A green suite proves only the inputs you picked — and 100% coverage measures lines run, not behaviour checked, so a weak assertion lets a wrong value pass on a fully covered line.

Now dismantle the metric everyone reaches for to feel safe: coverage. Line coverage instruments the run — a tracer (via sys.settrace, or sys.monitoring in 3.12+) records which line numbers executed while your tests ran, and coverage percent is executed lines over total lines. Here is its exact, fatal failure: executed is not verified. A line runs during your test whether or not any assert checks what that line produced. Watch it fail live:

coverage_lies.pypython
def total(items):
    return len(items)     # BUG: should be sum(items)

def test_total():
    result = total([200, 245])
    assert isinstance(result, int)   # passes! len returns an int too

# coverage: 100% — the buggy line ran.
# correctness: 0%  — result is 2, not 445, and no assert pinned the VALUE.

Read that and let it sting. The buggy return len(items) executed, and coverage proudly reports 100% of the function covered. The test passed green, because len returns an int just like sum does, and the only thing the assert pinned was the type. The wrong value — 2 instead of 445 — sailed straight through, on a fully-covered line, because no assertion ever looked at it. Coverage measured reach. It structurally cannot measure correctness, because correctness lives in the assertions, not in the execution. Push to branch coverage (did both sides of every if run) and it is a genuine refinement. But it does not touch this core gap: you can traverse every branch of a function and still assert nothing meaningful about what any of them produced.

SYNTAX · the honest read of a green barthere is no command that prints your code is correct — but there is one you can type to find out what your suite never checks
-- four claims over one function, ranked by what they actually pin down def test_it_runs(): press_cost(340) -- pins NOTHING def test_type(): assert isinstance(x, int) -- pins the type def test_positive(): assert x > 0 -- pins the sign def test_value(): assert press_cost(340) == 1080 -- pins THE ANSWER -- the honest measurement, by hand, no tool required: MUTATION 1. break one line of the code on purpose (that broken copy is a mutant) 2. python -m pytest -q 3. still green? -> no test was ever checking that line 4. put the line back python -m pytest -q -- read it as 'N claims held'. nothing more. python -m pytest --collect-only -q -- the inventory. what is NOT in this list? python -m pip install coverage -- lines EXECUTED. never lines verified. python -m coverage run -m pytest python -m coverage report -m -- the Missing column is the only useful one
green“Every claim I wrote still holds.” It is completely silent about every input you did not sample and every value you did not pin.
a mutantOne deliberate break in the code. A mutant that survives names a behaviour no assertion in your suite can feel — and it costs you thirty seconds to find.
isinstancePasses for len() when you meant sum(), because both return an int. The weakest assert in the language, and the most common.
coverage %Lines executed over lines total. It measures reach, and reach is not truth: a line runs whether or not anything checked what it produced.
branch coverageA genuine refinement — did both sides of every if run — with the identical gap. Traversed is still not verified.
the unsampled edge[], 0, -1, the empty string, the boundary, the value one past it. Bugs cluster there because that is where the branches change.
--collect-onlyYour suite's honest inventory. Read the ids out loud and ask the only question that matters: which behaviour is not in this list?
you type
# ---------- press.py ----------
def press_cost(seconds):
    """Cost in cents to press one side. Sides over 300s cost double."""
    if seconds <= 0:
        return 0
    return 200 + seconds          # BUG: the over-300s doubling was never written


# ---------- test_press.py ----------
from press import press_cost


def test_it_runs():
    press_cost(340)                          # no assert at all


def test_returns_an_int():
    assert isinstance(press_cost(340), int)


def test_costs_something():
    assert press_cost(340) > 0


def test_nothing_is_free():
    assert press_cost(0) == 0


# ---------- the terminal ----------
$ python -m pytest -q
$ sed -i 's/200 + seconds/250 + seconds/' press.py     # plant a MUTANT
$ python -m pytest -q
$ sed -i 's/250 + seconds/200 + seconds/' press.py     # put the line back

# ---------- now add the one claim nobody wrote ----------
def test_a_long_side_costs_double():
    assert press_cost(340) == 1080

$ python -m pytest -q
you see
$ python -m pytest -q
....                                                                     [100%]
4 passed in 0.01s

$ sed -i 's/200 + seconds/250 + seconds/' press.py
$ python -m pytest -q
....                                                                     [100%]
4 passed in 0.01s

$ sed -i 's/250 + seconds/200 + seconds/' press.py
$ python -m pytest -q
....F                                                                    [100%]
================================== FAILURES ===================================
________________________ test_a_long_side_costs_double ________________________

    def test_a_long_side_costs_double():
>       assert press_cost(340) == 1080
E       assert 540 == 1080
E        +  where 540 = press_cost(340)

test_press.py:21: AssertionError
=========================== short test summary info ===========================
FAILED test_press.py::test_a_long_side_costs_double - assert 540 == 1080
1 failed, 4 passed in 0.03s
where beginners trip
  • Every line of press_cost executed in that first run. Coverage would print 100%, and the function is charging less than half the right price.
  • The mutant changed the returned number by 50 cents and all four tests stayed green. That is the measurement: nothing in the suite was ever looking at the value.
  • test_it_runs has no assert, so it cannot fail. It is green over any bug you are capable of writing.
  • press_cost(340) > 0 is a mood, not a claim. press_cost(0) == 0 is a real one — and notice which of the two survived the mutant.
  • A suite you have never watched go red may be asserting nothing at all. Break the code once, on purpose, and confirm it screams.
  • The bug was never “in the tests”. It was in the input nobody sampled and the value nobody pinned — and the fifth test found it in one line.

So what actually determines a suite's proof-strength? Two things, and neither of them is coverage. First, assertion quality: what each test pins down — value, type, side effect, raised error. An isinstance check pins almost nothing, while assert result == 445 pins the answer. Second, input sampling: which regions you probe, like boundaries, empties, negatives, and the off-by-one edges where bugs actually cluster. A function correct on the three medium inputs you tried and wrong on the empty list is green until the day you add the empty-list case. The bug was always there, and your sample just never touched it.

NOW WRITE IT YOURSELFfour green tests over a broken function — find the hole, write the one claim that catches it, prove it, then check your own work
First, read the docstring, not the code. Here is skip.py, one function: def next_index(current, count): with the docstring “Index of the next track. Wraps back to 0 at the end of the playlist.” and the body return current + 1. Beside it, test_skip.py holds four tests, all green: test_it_runs calls next_index(0, 3) and asserts nothing; test_returns_an_int asserts isinstance(next_index(0, 3), int); test_advances_from_the_first asserts next_index(0, 3) == 1; test_advances_from_the_second asserts next_index(1, 3) == 2. Type all of it and run python -m pytest -q. Four dots. Second, answer three questions in writing before you touch the keyboard again. The docstring promises two behaviours. Name both, and say which one no test pins. Then, for a three-track playlist, what is the last valid index, and what should next_index return from it? Finally: which of the four tests would still be green if the body were return current + 1, return current + 1.0, or return abs(current) + 1? Third, write the missing test. One function, one claim, named for the behaviour it protects. Predict the exact number in the failure message before you run it, then run it and copy the red down. Fourth, fix it in one line and rerun. Five green. Fifth, and this is the part people skip: now break your own fix on purpose — try (current + 1) % (count + 1), then (current - 1) % count — and after each one, check that the suite goes red. A test you have never seen fail is a test you cannot trust yet.
show the solution
# ---------- skip.py, before ----------
def next_index(current, count):
    """Index of the next track. Wraps back to 0 at the end of the playlist."""
    return current + 1          # a bug lives here


# ---------- the four green tests ----------
$ python -m pytest -q
....                                                                     [100%]
4 passed in 0.01s


# The three answers.
#
# 1. Two behaviours: ADVANCE by one, and WRAP to 0 at the end. Three tests pin
#    advancing (weakly: one pins nothing, one pins only the type). Zero tests
#    pin wrapping -- and wrapping is the only behaviour the code gets wrong.
#
# 2. A three-track playlist has indices 0, 1, 2. The last valid index is 2, and
#    next_index(2, 3) must be 0. That single input is the entire hole.
#
# 3. `current + 1`    -- all four green. That IS the bug, sitting in plain sight.
#    `current + 1.0`  -- exactly ONE red: test_returns_an_int. The two value
#                        tests stay green, because 1.0 == 1 is True in Python.
#                        Only the isinstance check can feel this one.
#    `abs(current)+1` -- all four green. No test ever passes a negative, so the
#                        mutant is invisible. Two survivors out of three.


# ---------- the missing test ----------
def test_wraps_at_the_end():
    assert next_index(2, 3) == 0


$ python -m pytest -q
....F                                                                    [100%]
================================== FAILURES ===================================
____________________________ test_wraps_at_the_end ____________________________

    def test_wraps_at_the_end():
>       assert next_index(2, 3) == 0
E       assert 3 == 0
E        +  where 3 = next_index(2, 3)

test_skip.py:21: AssertionError
=========================== short test summary info ===========================
FAILED test_skip.py::test_wraps_at_the_end - assert 3 == 0
1 failed, 4 passed in 0.03s


# ---------- skip.py, after: one line ----------
def next_index(current, count):
    """Index of the next track. Wraps back to 0 at the end of the playlist."""
    return (current + 1) % count


$ python -m pytest -q
.....                                                                    [100%]
5 passed in 0.01s


# The two mutants, and what they prove.
#
#   (current + 1) % (count + 1)
#       1 failed, 4 passed   ->  assert 3 == 0  where 3 = next_index(2, 3)
#       Only the new test feels it. The four originals never look past index 1.
#
#   (current - 1) % count
#       3 failed, 2 passed   ->  assert 2 == 1  where 2 = next_index(0, 3)
#                                assert 0 == 2  where 0 = next_index(1, 3)
#                                assert 1 == 0  where 1 = next_index(2, 3)
#       Everything screams, and each red names the exact input it was given.
#
# Neither of those would have been caught before. That is the honest difference
# the fifth test bought -- not the fix, the ALARM standing around the fix.
#
# The one sentence to keep:
#   Green never told you the code was wrong, because nothing in the suite ever
#   asked it the one question the docstring had already promised an answer to.
THE ONLY QUESTION GREEN SHOULD PROMPT
Read a green bar as 'these N claims hold' — never as 'the code is correct'. Then ask the one question that matters: what could be broken while every one of these still passes? The unwritten test is the bug's hiding place. Green is a floor under the behaviors you thought to check, not a ceiling over the ones you didn't.

If you want an honest measure of a suite — one that can't be fooled by lines that merely ran — the tool is mutation testing: deliberately corrupt the code (flip a + to a -, an < to <=) and rerun. If a test dies, that behavior was really being checked. If the suite stays green on the mutant, no test ever pinned that behavior, however high your coverage number. Mutation testing measures whether your assertions can feel a bug; coverage only measures whether your feet walked the floor. Keep the distinction for life.

Interactive · coverage vs. mutation scoredrag the assertion
Same code, same 100% coverage — one assertion decides everything assert isinstance(result, int) LINE COVERAGE 100% MUTATION SCORE 25% 8 deliberately planted bugs (mutants) — which does your one assert actually catch? sum → len ✗ survives result + 1 ✗ survives result − 1 ✗ survives return 0 ✗ survives result × 2 ✗ survives return None ✗ survives return str(…) ✗ survives sum → max ✗ survives Coverage 100% · mutation 25% — 6 of 8 planted bugs survive, unnoticed. Coverage counts floors your feet walked. Mutation counts bugs your asserts can feel.
isinstance

The suite tests total([200, 245]), whose right answer is 445. Drag the one assertion from pass to == 445 and watch the two bars diverge: line coverage never leaves 100% — every line runs no matter what — while the mutation score, the fraction of deliberately-planted bugs your assertion is strong enough to kill, climbs from 0% to 100%. The chapter's villain, isinstance(result, int), sits at a fully-covered, feels-safe 25%. Coverage measured your feet; only the assertion measures your eyes.

If green only proves what you asserted, then the discipline is in when you write the assert. Next: red-green-refactor, and how a killed bug becomes a permanent antibody. →

07Red-green-refactor and the regression test

Two fears converge here, and one answer resolves both. The first: you are about to change working code to make it cleaner, and you are paralyzed. How do you restructure without breaking something you can't see? The second is deeper. You just spent three hours hunting a bug, found it, fixed one character, and moved on. And in six months someone will reintroduce that exact bug, and you will hunt it again from scratch. The three hours of knowledge you paid for evaporated the moment you deleted your debug prints. Both fears have the same cure: a test written at the right moment. Red-green-refactor gives you a rhythm that makes refactoring safe by construction, and the regression test turns each bug you kill into a permanent antibody. The truth to land: a bug's most valuable byproduct is not the fix. It is the test that guarantees the bug never returns.

Red-green-refactor is a discipline that cashes in the isolation and re-runnability the earlier sections built. The loop, mechanically:

red_green_refactor.pypython
# 1. RED — write the test FIRST, for behavior that doesn't exist yet, and RUN it.
def test_empty():
    assert total([]) == 0       # fails today — total([]) raises

# 2. GREEN — write the MINIMUM code to pass. The test is now your spec.
def total(items):
    return sum(items)             # sum([]) is 0 — green

# 3. REFACTOR — change structure freely; the green test is a tripwire.

RED is the step people skip, and skipping it is a quiet disaster. You write the test before the code and run it to watch it fail. Why? Because a test you never saw fail might be asserting nothing at all (recall the empty def test_nothing(): pass from section 1, and the coverage-lies trap from section 6). Watching it go red proves the test can fail, which proves it actually exercises the thing you think it does. GREEN is writing the least code that passes, and the test is now your executable definition of done. REFACTOR is the payoff. You change the implementation freely — sum() to a hand-rolled loop, one function into three — and the green test is a tripwire that fires the instant you break observable behavior. This is the whole reason 'refactor' is a real word and not a synonym for 'rewrite and hope'. Refactoring means changing structure without changing behavior, and 'without changing behavior' is only a checkable claim if behavior is pinned by tests.

Red-green-refactor — and tests as long-term memory1. REDwrite a failing test2. GREENminimal code to pass3. REFACTORreshape, stay greennext behaviourBug diary: a regression test remembers the bug forevertimebugreportedregressiontest (RED)fixed(GREEN)months later:careless editold test firesRED instantlythe same bug can never return silently once a test remembers it
Fig — Each behaviour cycles red to green to refactor with the green test as a guardrail; a regression test written for a fixed bug stays in the suite and fires the instant a later edit brings the bug back.

Now the highest-value instance of RED: the regression test. A bug gets reported — total() crashes on the empty list. Before you fix it, you write a test that reproduces it: assert total([]) == 0, which is red right now, because of the bug. Then you fix the bug, and the test turns green. And here is the part that pays forever. That test now lives in the suite permanently, and its single job is to fail loudly if this exact bug ever comes back — through a careless edit, a bad merge, a refactor gone wrong. You paid three hours to understand the bug once. The regression test banks that understanding so no one ever has to pay it again.

NOW WRITE IT YOURSELFchapter 13's validator, tested from the outside — six claims, four of them about failing well, and one trap that passes for the wrong reason
First, type the code under test. This is Chapter 13's robust input with the while loop stripped off, which is what makes it testable: to_int(text, low=None, high=None) tries int(text) and, on ValueError, re-raises ValueError(f"not a whole number: {text!r}") from e; then it raises ValueError(f"{value} is below the minimum {low}") or ... is above the maximum {high} when the bounds are broken; otherwise it returns the int. Second, write six tests in test_ask.py. One for the happy path. One for junk text, using match= so the message is part of the claim. One for below the minimum and one for above it. One that captures the exception with as excinfo and proves Chapter 13's chain survived — that excinfo.value.__cause__ is the original ValueError from int(). And one for the boundaries themselves, which are the values most likely to be off by one. Run it: six passed. Third, spring the trap. In a scratch folder, write a deliberately broken to_int that does nothing but return int(text) — no translation, no bounds at all. Point two tests at it: one using bare pytest.raises(ValueError) on to_int("abc"), one using match=r"not a whole number". Predict both verdicts, then run. Fourth, write down the rule you just discovered in one sentence, and say what it costs you to follow it.
show the solution
# ---------- ask.py ----------
# chapter 13's robust input, with the loop stripped off so it is testable
def to_int(text, low=None, high=None):
    """Parse text as a whole number inside [low, high]. Refuse anything else."""
    try:
        value = int(text)
    except ValueError as e:
        raise ValueError(f"not a whole number: {text!r}") from e
    if low is not None and value < low:
        raise ValueError(f"{value} is below the minimum {low}")
    if high is not None and value > high:
        raise ValueError(f"{value} is above the maximum {high}")
    return value


# ---------- test_ask.py ----------
import pytest

from ask import to_int


def test_a_good_string_comes_back_as_an_int():
    assert to_int("245", low=1, high=600) == 245


def test_junk_names_what_it_saw():
    with pytest.raises(ValueError, match=r"not a whole number: 'abc'"):
        to_int("abc")


def test_below_the_minimum():
    with pytest.raises(ValueError, match=r"below the minimum 1"):
        to_int("0", low=1, high=600)


def test_above_the_maximum():
    with pytest.raises(ValueError, match=r"above the maximum 600"):
        to_int("601", low=1, high=600)


def test_the_original_error_is_kept_as_the_cause():
    with pytest.raises(ValueError) as excinfo:
        to_int("4:05")
    assert isinstance(excinfo.value.__cause__, ValueError)
    assert "invalid literal" in str(excinfo.value.__cause__)


def test_the_boundaries_are_inclusive():
    assert to_int("1", low=1, high=600) == 1
    assert to_int("600", low=1, high=600) == 600


$ python -m pytest -q
......                                                                   [100%]
6 passed in 0.01s


# ---------- the trap: a to_int that validates NOTHING ----------
def to_int(text, low=None, high=None):
    value = int(text)                       # no translation, no bounds at all
    return value


import pytest
from ask import to_int


def test_bare_raises_is_fooled():
    with pytest.raises(ValueError):         # green -- but for the WRONG reason
        to_int("abc")


def test_match_is_not_fooled():
    with pytest.raises(ValueError, match=r"not a whole number"):
        to_int("abc")


$ python -m pytest -q
.F                                                                       [100%]
================================== FAILURES ===================================
__________________________ test_match_is_not_fooled ___________________________

    def test_match_is_not_fooled():
>       with pytest.raises(ValueError, match=r"not a whole number"):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: Regex pattern did not match.
E         Expected regex: 'not a whole number'
E         Actual message: "invalid literal for int() with base 10: 'abc'"

test_trap.py:12: AssertionError
=========================== short test summary info ===========================
FAILED test_trap.py::test_match_is_not_fooled - AssertionError: Regex pattern...
1 failed, 1 passed in 0.03s


# The rule, and its price.
#
#   A bare pytest.raises(ValueError) asserts only that SOMETHING went wrong. It
#   is green when int() blew up on its own and your validator never ran a line.
#   match= asserts WHICH thing went wrong, so the test can tell your error apart
#   from the accident underneath it.
#
#   The price: match= is a regex, so a reworded message breaks the test. That is
#   the trade you are making -- the message becomes part of the contract. Match
#   on the stable half of the sentence ("not a whole number"), never on the
#   whole line, and never on a formatted number you might reformat next month.
#
# The one sentence to keep:
#   Testing that it raises is easy. Testing that it raises for YOUR reason is
#   the test.

Step back and see what a mature suite actually is. Section 6 killed the idea that it proves correctness — it doesn't, and it can't. So what is it? It is institutional memory. Every regression test is a dated entry in a diary: on this day, this input broke this function. Here is the claim that ensures it never breaks the same way again. A suite is not a proof of correctness, but the accumulated scar tissue of every bug the code has survived. And that reframes an old intuition. Old code with a real suite is safer to change than new code without one, precisely because the old code's suite remembers hundreds of specific ways it once broke, and stands guard over every one of them.

NOW WRITE IT YOURSELFthree bad tests, all green — name the smell in each, prove it with a command, then rewrite the file
First, type the file exactly as it stands and run it. Four tests, four dots, everything green. It imports add_song, as_clock and total from Chapter 19's tracks.py, and it opens with a module-level SHELF = []. Test 1 is test_add_song: it builds a local shelf, calls add_song(shelf, "Titanium", 245), and ends there. Test 2 is test_the_playlist: it adds a track, asserts len(shelf) == 1, adds another, asserts total(shelf) == 445, then asserts as_clock(total(shelf)) == "7:25". Tests 3 and 4 are test_a_adds_one and test_b_adds_another, and both of them append to the shared module-level SHELF. Second, name each smell in one phrase, and the standard names are short. For each one write the sentence that says what the test fails to prove. Third, prove all three with a command, not an opinion. For test 1, edit add_song so it returns without appending, and see whether test 1 notices. For test 2, break two things at once: make total return len() and drop the :02d from as_clock. Then run it and count how many of the two bugs the report names. For tests 3 and 4, change nothing at all: just run them in the other order with python -m pytest "test_smells.py::test_b_adds_another" "test_smells.py::test_a_adds_one". Fourth, rewrite the whole file. One behaviour per test, a fixture instead of the shared list, and every claim pinning a value. Then run your new file in both orders and confirm the verdict does not move.
show the solution
# ---------- test_smells.py -- as given, all four green ----------
from tracks import add_song, as_clock, total

SHELF = []                                  # one list, shared by the whole file


def test_add_song():                        # SMELL 1
    shelf = []
    add_song(shelf, "Titanium", 245)


def test_the_playlist():                    # SMELL 2
    shelf = []
    add_song(shelf, "Blinding Lights", 200)
    assert len(shelf) == 1
    add_song(shelf, "Titanium", 245)
    assert total(shelf) == 445
    assert as_clock(total(shelf)) == "7:25"


def test_a_adds_one():                      # SMELL 3
    add_song(SHELF, "Blinding Lights", 200)
    assert len(SHELF) == 1


def test_b_adds_another():                  # SMELL 3
    add_song(SHELF, "Titanium", 245)
    assert total(SHELF) == 445


$ python -m pytest -q
....                                                                     [100%]
4 passed in 0.01s


# ============ SMELL 1: ASSERTS NOTHING (a smoke test wearing a test's name) ==
# It proves the call did not raise. It proves nothing about what the call did.
# Proof -- make add_song return without appending, then run both versions:

    def test_add_song():                   # asserts nothing
        shelf = []
        add_song(shelf, "Titanium", 245)

    def test_add_song_appends_the_row():   # pins the result
        shelf = []
        add_song(shelf, "Titanium", 245)
        assert shelf == [("Titanium", 245)]

$ python -m pytest -q
.F                                                                       [100%]
________________________ test_add_song_appends_the_row ________________________

    def test_add_song_appends_the_row():   # pins the result
        shelf = []
        add_song(shelf, "Titanium", 245)
>       assert shelf == [("Titanium", 245)]
E       AssertionError: assert [] == [('Titanium', 245)]
E
E         Right contains one more item: ('Titanium', 245)

# The dot is the assertless test, still green over a function that does nothing.
# FIX: assert the result, not the absence of a crash.


# ============ SMELL 2: TWO (here THREE) BEHAVIOURS IN ONE TEST ==============
# Proof -- plant TWO bugs (total -> len, as_clock -> no zero-pad) and run it:

$ python -m pytest -q
F                                                                        [100%]
______________________________ test_the_playlist ______________________________

    def test_the_playlist():
        shelf = []
        add_song(shelf, "Blinding Lights", 200)
        assert len(shelf) == 1
        add_song(shelf, "Titanium", 245)
>       assert total(shelf) == 445          # fails HERE
        ^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: assert 2 == 445
E        +  where 2 = total([('Blinding Lights', 200), ('Titanium', 245)])

1 failed in 0.03s

# Two bugs in the file, ONE reported. The assert on as_clock never ran, so the
# formatting bug is invisible until you fix the first one and run again. And the
# name in the report -- 'test_the_playlist' -- names no behaviour at all.
# FIX: one behaviour per test, and the test's name IS the failure report.


# ============ SMELL 3: ORDER-DEPENDENT (shared mutable state) ===============
# Proof -- change no code. Run the SAME two tests in the other order:

$ python -m pytest -q "test_smells.py::test_b_adds_another" \
                      "test_smells.py::test_a_adds_one"
FF                                                                       [100%]
_____________________________ test_b_adds_another _____________________________
>       assert total(SHELF) == 445
E       AssertionError: assert 245 == 445
E        +  where 245 = total([('Titanium', 245)])
_______________________________ test_a_adds_one _______________________________
>       assert len(SHELF) == 1
E       AssertionError: assert 2 == 1
E        +  where 2 = len([('Titanium', 245), ('Blinding Lights', 200)])

2 failed in 0.03s

# Both of them. The pair was never two tests -- it was one script split in half,
# and file order was load-bearing. This is the bug that only reproduces on CI.
# FIX: a function-scoped fixture. Fresh object per test, by construction.


# ---------- test_fixed.py -- the rewrite ----------
import pytest

from tracks import add_song, as_clock, total


@pytest.fixture
def shelf():
    return []                               # fresh list per test -- no sharing


def test_add_song_appends_the_row(shelf):           # FIX 1: pin the result
    add_song(shelf, "Titanium", 245)
    assert shelf == [("Titanium", 245)]


def test_add_song_keeps_the_order(shelf):           # FIX 2: one behaviour each
    add_song(shelf, "Blinding Lights", 200)
    add_song(shelf, "Titanium", 245)
    assert shelf == [("Blinding Lights", 200), ("Titanium", 245)]


def test_total_sums_two_rows():
    assert total([("Blinding Lights", 200), ("Titanium", 245)]) == 445


def test_as_clock_formats_the_total():
    assert as_clock(445) == "7:25"


$ python -m pytest test_fixed.py -q
....                                                                     [100%]
4 passed in 0.01s

$ python -m pytest -q "test_fixed.py::test_add_song_keeps_the_order" \
                      "test_fixed.py::test_add_song_appends_the_row"
..                                                                       [100%]
2 passed in 0.00s

# Same two tests, other order, same verdict. That is what isolation looks like
# from the outside, and it cost one fixture.
#
# The one sentence to keep:
#   A test that asserts nothing cannot fail, a test that asserts two things
#   hides one of them, and a test that shares state asserts something about the
#   test before it.
NEVER FIX A BUG WITHOUT A FAILING TEST FIRST
The temptation is to fix the one character and move on. Resist it. Write the test that reproduces the bug, watch it go red, then fix. The red proves you reproduced the real bug and not a phantom; the fix turning it green proves you actually killed it; and the test surviving in the suite proves it can never come back silently. A fix without a regression test is a bug you'll meet again.

And that closes the whole chapter's circle. The regression test is an executable claim (§1), discovered by collection (§2), whose failure talks through the rewrite (§3). It is often fed by a fixture (§4) and generalized by parametrize (§5). And it is written by someone who knows, without illusion, that it proves only what it asserts (§6). Green never meant 'correct.' It meant 'the claims I was disciplined enough to write are still true.' Make those claims sharp, sample the edges where bugs hide, and turn every bug you kill into a claim that outlives you. That is the whole craft.

You can now keep a claim about your code alive forever — and you know exactly what that claim does and doesn't prove. Next volume-thread stop: when the thing crossing the boundary isn't a value you can assert on, but bytes on a wire. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 24, in working code

In Volume 1 you learned to run code; here you learn to trust it. A test is nothing but an assert wrapped in a function — silent when a claim holds, raising AssertionError when it breaks. These twelve programs build a test runner from scratch, then turn it on the tests themselves, because "green" only ever means "the claims you bothered to write held for the inputs you happened to try."

The assert statement
A test is just an assert that either stays silent or raises AssertionError. Everything else — runners, fixtures, reports — is scaffolding around this one statement.
A test runner is a loop over functions
pytest finds tests by name, imports the file (running its top-level code), and calls each test in its own try/except so one failure can't kill the rest. Here is that machinery in plain Python.
Parametrize: many cases, independent verdicts
One parametrized test is really N independent tests, each with its own verdict. A hand-written for-loop looks similar but collapses them into one result that stops at the first failure.
What green actually proves
Green means the assertions you wrote held for the inputs you tried — no more. Type-only checks, assert-free tests, and untested branches all show green over real bugs; mutation testing probes whether green truly pins the behavior.
end of chapter 24 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked