Skip to content

Chapter 3 · The Art of Assertions ​

Chapter goals: understand how pytest's assertion rewriting works, and learn to write high-quality, self-explaining assertions with pytest.raises, pytest.warns, and pytest.approx.

3.1 Assertion Rewriting: Why Plain assert Works So Well ​

unittest needs dozens of assertXxx methods (assertEqual, assertIn, assertIsNone…); pytest needs one native assert. The secret is assertion rewriting: when pytest imports your test modules it intercepts compilation and rewrites each assert statement into equivalent code that carries intermediate values. Failures can then show sub-expression evaluation results:

python
# test_assert_demo.py
def f():
    return 3


def test_function():
    assert f() == 4
text
>       assert f() == 4
E       assert 3 == 4
E        +  where 3 = f()

where 3 = f() is the payoff of rewriting — it prints the actual return value of the call expression. Attribute access, comparisons, and binary/unary operators all get the same introspection.

You can also attach an explanatory message to an assertion; it prints alongside the introspection on failure:

python
def test_even(n=3):
    assert n % 2 == 0, "value was odd, should be even"

Limits of Rewriting

Assertion rewriting only happens for modules whose import pytest manages. If you assert behavior inside a third-party library's internals, the failure output shows only the final result — no expansion of the library's internal expressions. That's not a bug; it's the scope of rewriting.

3.2 Floats and Collections: pytest.approx Patterns ​

Never compare floats with ==; the official recommendation is pytest.approx:

python
import pytest


def test_floats():
    # classic trap: 0.1 + 0.2 == 0.30000000000000004
    assert (0.1 + 0.2) == pytest.approx(0.3)


def test_relative_and_abs_tolerance():
    # default relative tolerance is 1e-6; both tolerances are explicit here
    assert 100.0 == pytest.approx(100.001, rel=1e-4)
    assert 0.0 == pytest.approx(1e-9, abs=1e-6)

approx also handles element-wise approximate comparison for lists, dicts, and NumPy arrays — and even gets NaN equality semantics right.

When asserting collections, decide first whether you want "order-independent equality" or "containment":

python
def test_permissions():
    granted = {"read", "write"}

    # order-independent equality: compare sets rather than sorted() ==
    assert granted == {"write", "read"}

    # containment: a subset check expresses intent better than repeated `in`
    assert {"read", "write"} <= {"read", "write", "admin"}

3.3 Exception Assertions: Using pytest.raises Correctly ​

To verify "this code should raise", use the context-manager form:

python
import pytest


def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

To inspect the exception message, capture an ExceptionInfo with as excinfo (core attributes: .type, .value, .traceback):

python
def parse_age(text):
    age = int(text)
    if age < 0:
        raise ValueError(f"age must be non-negative, got {age}")
    return age


def test_negative_age_message():
    with pytest.raises(ValueError) as excinfo:
        parse_age("-5")
    # assert on the exception message content
    assert "non-negative" in str(excinfo.value)

The terser option is the match parameter — it takes a regex applied via re.search against the exception's string form:

python
def test_match_parameter():
    with pytest.raises(ValueError, match=r"got -\d+"):
        parse_age("-7")

Two high-frequency traps to remember:

  1. Type matching follows inheritance. NotImplementedError subclasses RuntimeError, so pytest.raises(RuntimeError) will happily accept a NotImplementedError. Tighten with an explicit check: excinfo.type is RuntimeError;
  2. Wrap only the line that should raise. If setup code sneaks into the with block and throws unexpectedly, the test "passes" falsely.
python
def test_exact_type():
    def foo():
        raise NotImplementedError

    with pytest.raises(RuntimeError) as excinfo:
        foo()
    # raises passed due to subclassing; tighten to the exact type explicitly
    assert excinfo.type is RuntimeError

Anti-Pattern: Manual try/except

Writing try: ... except ExpectedError: pass else: pytest.fail(...) inside tests is verbose and easy to get wrong by omitting the else branch. Always use pytest.raises.

3.4 Warning Assertions: pytest.warns ​

To verify code emits expected warnings, the structure mirrors raises exactly:

python
import warnings

import pytest


def deprecate_api():
    warnings.warn("old_api is deprecated", DeprecationWarning)


def test_deprecation_warning():
    with pytest.warns(DeprecationWarning, match="deprecated"):
        deprecate_api()

pytest.warns provides an excinfo-style record too, so you can check how many warnings fired plus each category and message; if no matching warning is emitted inside the block, the test fails. Its complement is the @pytest.mark.filterwarnings marker, used to silence irrelevant warnings or escalate them to errors (e.g. -W error::DeprecationWarning makes deprecated API calls fail tests outright).

3.5 Organizing Multiple Assertions ​

Multiple assertions in one test are fine — with two caveats:

  • Assertions should serve the same behavioral verification. Once the first fails, later ones don't execute — so unrelated checks belong in separate tests;
  • When you genuinely want "see every problem at once" (e.g. validating bulk data), collect errors and report them together:
python
def test_user_record_shape():
    user = {"name": "Alice", "age": 30, "roles": ["admin"]}

    errors = []
    if "name" not in user:
        errors.append("missing name")
    if not isinstance(user["age"], int):
        errors.append("age must be int")
    if "admin" not in user["roles"]:
        errors.append("must have admin role")

    # surface every issue at once instead of fix-one-run-one
    assert not errors, f"schema problems: {errors}"

3.6 Chapter Summary ​

  • Assertion rewriting rewrites assert at import time so failures print sub-expression values (where ... = ...);
  • Use pytest.approx for floats (rel/abs tolerance, lists, dicts, NumPy); choose set equality vs subset checks by semantics;
  • pytest.raises + match= is the standard recipe; watch subclass matching and tighten with excinfo.type is;
  • pytest.warns mirrors raises; multiple assertions either target one behavior or aggregate into a single report.

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. Where does pytest’s failure output "+ where 3 = f()" come from?

2. Can with pytest.raises(RuntimeError) catch a NotImplementedError?

3. Which statement about pytest.raises’ match parameter is correct?

4. Best way to assert 0.1 + 0.2 approximately equals 0.3?

🛠️ Hands-On Exercises ​

  1. Write a divide(a, b) function that raises ZeroDivisionError with a helpful message on division by zero; cover it with exception tests using both as excinfo and match=.
  2. Use pytest.approx to verify [0.1*3, 0.2*3] approximately equals [0.3, 0.6] as a list, then loosen rel deliberately and observe when it stops failing.
  3. Add logic to one of Chapter 2's functions that raises a UserWarning on invalid input, then verify the message content with pytest.warns.

With assertions sharpened, the next chapter covers organizing hundreds of tests and tagging them.