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, andpytest.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:
# test_assert_demo.py
def f():
return 3
def test_function():
assert f() == 4> 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:
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:
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":
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:
import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0To inspect the exception message, capture an ExceptionInfo with as excinfo (core attributes: .type, .value, .traceback):
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:
def test_match_parameter():
with pytest.raises(ValueError, match=r"got -\d+"):
parse_age("-7")Two high-frequency traps to remember:
- Type matching follows inheritance.
NotImplementedErrorsubclassesRuntimeError, sopytest.raises(RuntimeError)will happily accept aNotImplementedError. Tighten with an explicit check:excinfo.type is RuntimeError; - Wrap only the line that should raise. If setup code sneaks into the
withblock and throws unexpectedly, the test "passes" falsely.
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 RuntimeErrorAnti-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:
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:
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
assertat import time so failures print sub-expression values (where ... = ...); - Use
pytest.approxfor 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 withexcinfo.type is;pytest.warnsmirrorsraises; 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
- Write a
divide(a, b)function that raisesZeroDivisionErrorwith a helpful message on division by zero; cover it with exception tests using bothas excinfoandmatch=. - Use
pytest.approxto verify[0.1*3, 0.2*3]approximately equals[0.3, 0.6]as a list, then loosenreldeliberately and observe when it stops failing. - Add logic to one of Chapter 2's functions that raises a
UserWarningon invalid input, then verify the message content withpytest.warns.
With assertions sharpened, the next chapter covers organizing hundreds of tests and tagging them.