How assertion rewriting works

pytest · Expert · 12 min

Understanding the rewriting hook explains missing failure details in helpers, odd caching issues and plugin requirements.

Python's assert just raises AssertionError with no information about the values involved. pytest gets its detailed messages by rewriting the AST of test modules before they're compiled.

At startup pytest installs a PEP 302 import hook (a meta path finder). When a test module or conftest.py is imported, the hook parses the source, replaces each assert with code that evaluates every sub-expression into temporary variables, and on failure builds an explanation from them, calling pytest_assertrepr_compare hooks for type-specific diffs (sets, dicts, dataclasses, long strings). The rewritten bytecode is cached in __pycache__ with a pytest-specific tag, so it only happens when sources change.

Consequences:

  • Only modules matched by test discovery, conftest files and registered modules are rewritten. A shared helper module with asserts (say tests/helpers.py with assert_valid_order) gives bare AssertionErrors unless you call pytest.register_assert_rewrite("tests.helpers") before it's imported, typically in the root conftest.
  • Plugins in installed packages are rewritten automatically when loaded through entry points.
  • Modules imported before the hook is installed can't be rewritten; pytest warns about this.
  • --assert=plain disables rewriting, and a module docstring containing PYTEST_DONT_REWRITE opts a module out.

You can extend explanations for your own types by implementing pytest_assertrepr_compare(config, op, left, right) in conftest.

Example

# conftest.py
import pytest

# Must run before tests.helpers is imported anywhere
pytest.register_assert_rewrite("tests.helpers")

class Money:
    def __init__(self, cents): self.cents = cents
    def __eq__(self, other): return self.cents == other.cents

def pytest_assertrepr_compare(config, op, left, right):
    if isinstance(left, Money) and isinstance(right, Money) and op == "==":
        return ["Comparing Money:",
                f"   {left.cents / 100:.2f} != {right.cents / 100:.2f}"]

# tests/test_money.py
# def test_total():
#     assert Money(1050) == Money(1000)
# E  assert Comparing Money:
# E       10.50 != 10.00

Remember

  • pytest rewrites asserts via an import hook at import time
  • Only test modules, conftest files and registered modules are rewritten
  • register_assert_rewrite() for helper modules, before they're imported
  • pytest_assertrepr_compare customizes failure explanations

Watch out for

  • Importing a helper module in conftest before calling register_assert_rewrite for it.

Check yourself

  1. A shared helper in tests/helpers.py fails with a bare AssertionError and no values. Why?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.pytest.org/en/stable/how-to/writing_plugins.html#assertion-rewriting