open atlas
↑ Back to track
Python for JS/TS developers PY · 07 · 01

pytest idioms: assertion rewriting, discovery, markers, and the debug loop

pytest rewrites assert via an AST import hook, but only in collected files — helpers need register_assert_rewrite. Discovery imports every test module, so one import-time singleton kills the whole suite. raises(match=), strict xfail, -k/-x/--lf and exit codes shape the CI loop.

PY Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Friday, 16:40. A teammate renames a config default in app/settings.py. The next CI run — on an unrelated, docs-only PR — goes red: not one failing test, but 1,847 errors in 11 seconds. Nothing actually ran. During collection pytest imports every test_*.py it discovers, and tests/payments/test_refunds.py opened with from app.db import engine — a module-level singleton that built a connection pool and pinged Postgres the moment the file was imported. The settings rename changed the DSN, the ping raised, and one import error at collection time took the whole suite hostage: every PR blocked, the deploy train halted, and the on-call engineer spent an hour reading a traceback that pointed at SQLAlchemy instead of a one-line settings change. The suite had been a loaded gun for two years — any test module that touches an application singleton at import time makes all tests depend on that import succeeding, before a single assertion gets the chance to fire.

Why a plain assert beats self.assertEqual

Ask yourself why your unittest failures say AssertionError: False is not true while pytest failures show every subexpression — the answer shapes how you structure every shared check in the codebase.

pytest installs an import hook (a meta-path finder) at startup. When it imports a module it intends to collect — a test module, a conftest.py, or a registered plugin — it parses the source to an AST, rewrites every assert statement into code that records the value of each subexpression, recompiles, and caches the rewritten bytecode in a specially tagged .pyc. That is the entire trick behind the famous failure output:

# test_pricing.py — collected, so the assert below is rewritten at import
def apply_discount(cents: int, pct: int) -> int:
    return cents * (100 - pct) // 100

def test_discount():
    assert apply_discount(1999, 15) == 1700
# E       assert 1699 == 1700
# E        +  where 1699 = apply_discount(1999, 15)

No assertion methods to memorize, no self.assertEqual(a, b) — the rewrite reconstructs what each side evaluated to. The boundary is what bites teams: only modules pytest itself imports for collection get rewritten. Move shared checks into tests/helpers.py and the asserts there still raise on failure, but as a bare AssertionError with no values — the diagnostic content is gone exactly where you centralized it. The fix is one line, placed before the helper is first imported (a root conftest.py is the standard spot): pytest.register_assert_rewrite("tests.helpers"). Two adjacent facts worth knowing: conftest.py files are rewritten automatically, and running Python with -O strips assert statements entirely — a test suite under -O passes vacuously, which is why pytest warns about it.

Quiz

Shared checks moved to tests/helpers.py: `def check_order(o): assert o.total == sum(i.price for i in o.items)`. Failures now report only a bare AssertionError with no values. Why?

Discovery: imports first, your code runs whether you like it or not

Given a target directory, pytest collects files matching test_*.py or *_test.py; inside them, test_-prefixed functions and Test-prefixed classes that do not define __init__. Before importing any test module, it imports the chain of conftest.py files from the rootdir down to that module’s directory — conftest is how fixtures and hooks become ambiently available without imports. The structural consequence is the Hook incident: collection means importing. Whatever runs at module level in a test file — or in anything it transitively imports — runs before any test executes. One module that builds a database engine, reads a missing env var, or instantiates an application singleton at import time produces a collection error; pytest reports Interrupted: 1 error during collection and runs nothing at all. A crashing conftest.py is worse: it poisons its entire directory subtree. The discipline that follows: test modules keep their top level to imports of pure code, and anything with side effects moves behind a fixture, where failure is scoped to the tests that asked for it. When suspicious, pytest --collect-only -q shows what would run — on a 5,000-test repo collection alone takes a few seconds, which is also your floor for pytest -k one_test.

The unittest baggage you can drop: pytest runs plain functions, so class TestX(unittest.TestCase) plus self.assertEqual ceremony adds nothing — and costs interop, since TestCase methods cannot receive fixtures as parameters and @pytest.mark.parametrize does not work on them. Grouping, when you want it, is a bare Test-prefixed class with no base.

raises, warns, and markers without the folklore

import pytest

def test_rejects_negative():
    with pytest.raises(ValueError, match=r"negative amount: -\d+"):
        charge(-500)          # keep the block to ONE line — the line under test

@pytest.mark.xfail(reason="half-cent rounding, bug #4231", strict=True)
def test_half_cent_rounding():
    assert price_total([("X", 1, 0.005)]) == "0.01"

pytest.raises(..., match=...) runs re.search against the string of the exception — a regex, so escape your brackets. The classic self-inflicted wound is a wide with block: three setup lines inside it, one of which raises the same exception type for a different reason, and the test passes while the code under test is broken. Keep exactly the failing call inside. pytest.warns has the same shape for warnings. Markers, honestly: skip documents a fact (“no Postgres on this platform”), skipif makes it conditional, and xfail documents a known bug while keeping the test running. The flag that separates disciplined suites from rotting ones is strict=True: an xfail test that unexpectedly passes (XPASS) then fails the suite, so a fixed bug surfaces immediately instead of hiding behind a stale marker for a year. Custom markers like @pytest.mark.slow must be registered under [tool.pytest.ini_options] markers or every use emits PytestUnknownMarkWarning — and --strict-markers upgrades unknown markers to collection errors, which is the only thing standing between you and @pytest.mark.slwo silently selecting nothing.

The debug loop and what CI actually sees

The loop that makes a 2,000-test suite livable: full run goes red, then pytest --lf -x — only the last-failed tests, stop at the first failure — until green, then one full run to confirm. --ff runs failures first but keeps the rest; -k "payments and not slow" selects by name expression; -x is the impatience flag. State for --lf lives in .pytest_cache. What CI sees is the exit code, and the table is short: 0 all passed, 1 some failed, 2 interrupted, 3 internal error, 4 usage error, 5 no tests collected. Code 5 is the trap: rename a directory or fatten a -m filter and a stage that runs zero tests goes red — correct behavior, annoying Monday. Teams that “fix” it with pytest -m smoke || true have masked exit 1 along with exit 5: failing smoke tests now pass CI. The honest fix handles 5 explicitly and lets 1 fail the job.

Quiz

A CI stage runs `pytest -m smoke || true` so the optional smoke suite never blocks deploys when it matches nothing. What did this actually buy?

Recall before you leave
  1. 01
    Walk through what happens between typing pytest and the first test running — and where a module-level singleton kills the suite.
  2. 02
    State the assert-rewrite boundary, the marker hygiene rules, and the exit-code contract for CI.
Recap

pytest is built on one structural decision: collection imports your code. The import hook that makes plain assert produce rich failure output — AST-rewriting every collected test module, conftest, and plugin into self-describing bytecode — is the same machinery that makes a module-level from app.db import engine lethal: side effects at import time run during collection, and one raised exception there marks the entire suite as errors before a single test executes. The rewrite has a boundary worth memorizing — helpers shared across tests keep raising, but bare, until register_assert_rewrite brings them inside — and a footgun in -O, which deletes asserts wholesale. Around that core sit the idioms that keep large suites honest: raises(match=) with a one-line block so the right exception from the right line is the only thing that can pass; xfail(strict=True) so a fixed bug fails loudly instead of rotting behind a marker; registered markers plus --strict-markers so typos cannot silently deselect tests; and the --lf -x loop for the red-to-green grind. CI sees none of this nuance — only an exit code, where 1 means failures and 5 means your filter matched nothing; treat them differently, and never let || true flatten them into success. Now when you see a CI stage go green after a rename that should have broken tests, check whether || true is swallowing exit 5 — and when failures report bare AssertionError with no values, check whether the helper module is inside the rewrite boundary.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.