Errors & Reporting¶
Structured errors¶
A failing assertion raises AssertionFailure, a subclass of AssertionError that carries structured
data. Existing except AssertionError handlers keep working unchanged.
from assertpy2 import assert_that
try:
assert_that(1).is_equal_to(2)
except AssertionError as e:
print(e.actual) # 1
print(e.expected) # 2
For comparisons, a DiffResult with path-level entries is attached:
try:
assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
except AssertionError as e:
print(repr(e.diff))
# DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
The diff is also rendered into the failure message, so it travels with str(e) wherever the
exception surfaces - unittest, a plain script, an AssertionError in a CI log:
try:
assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
except AssertionError as e:
print(e)
# Expected <{.., 'b': 2}> to be equal to <{.., 'b': 99}>, but was not.
# diff (dict):
# b:
# - 2
# + 99
Matcher-based assertions (matches_structure(), satisfies(), each()) attach a DiffResult with
kind='match', where each entry's expected holds the failed predicate's description.
Under pytest the plugin (auto-registered via the pytest11 entry point, no configuration needed) renders
this same diff as a dedicated colored report section instead, keeping the message itself to a single line
so the diff is never shown twice. See Rich pytest diffs for supported types and
configuration.
Rich pytest diffs¶
When is_equal_to() or contains()/contains_exactly() fail, the DiffResult on the exception is
rendered by the plugin as colored diff sections.
| Type | Diff kind | How it works |
|---|---|---|
list, tuple |
sequence |
Element-by-element, recursive into nested dicts, lists, dataclasses, namedtuples, attrs classes, and models |
set, frozenset |
set |
Extra and missing items |
str |
string |
Line-by-line, with difflib carets marking the exact intra-line change |
dict |
dict |
Key-by-key, recursive into nested dicts, lists, dataclasses, namedtuples, attrs classes, and models |
dataclass |
dataclass |
Field-by-field, handles differing types with overlapping fields |
namedtuple |
namedtuple |
Field-by-field comparison |
| Pydantic model | model |
Field-by-field via model_dump(), recursive into nested models |
| attrs class | attrs |
Field-by-field, recursive into nested fields |
| other | scalar |
Single actual-vs-expected entry |
contains family |
contains |
Missing and extra items |
| matcher mismatch | match |
matches_structure() / satisfies() / each(): path + failed predicate |
--- AssertionFailure ---
actual: [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
expected: [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Robert'}]
The diff for that failure - and the other diff shapes - renders like this.
The comparison is recursive, so for a very large or deeply nested value it walks the whole object graph. When the payload is huge and you only care about a few fields, extract those and assert on them instead of comparing the whole thing - it is faster and the failure stays focused. See When full structural comparison is too much.
What each diff kind looks like¶
Value diffs (sequence, dict, dataclass, namedtuple, attrs, Pydantic model, string, scalar)
show the path with the removal in red and the addition in green - this is the diff for the example above:
String values go finer than line-by-line: each changed line is diffed within the line, with difflib
carets (? ^^^) pointing at the exact span - the same guides pytest's own assertion rewriting uses:
Set and contains show extra items in red and missing items in green:
Match (matches_structure(), satisfies(), each()) shows each field's path and the predicate that
failed, with the actual value in red - every mismatch, not just the first (no green: a predicate has no
"addition"):
Nested structures are diffed recursively and report the exact path to the differing value (for example
[1].name). Circular references are detected and shown as <circular ref> rather than recursing forever.
When two values render to the same text but are not equal - most often because they differ only in type -
the message tags each with its type, so assert_that("1").is_equal_to(1) reads
Expected <1:str> to be equal to <1:int>, but was not. rather than a baffling <1> / <1>.
Note
Cycle detection applies to the diff rendering and to the selective-comparison path
(ignore / include), which treats a revisited pair as equal rather than recursing. The bare
equality check itself follows Python's own == semantics, so comparing two structurally equal
cyclic graphs raises RecursionError - exactly as a plain assert a == b would.
Polling failures carry a trace¶
An eventually() timeout attaches its convergence telemetry as
failure.trace - a PollTrace with per-poll
PollSample entries and a one-line trend summary. See
Polling trace.
Catching failures with their types intact¶
pytest.raises(AssertionError) types the caught exception as plain AssertionError, so a type
checker flags .actual / .expected / .diff access. Catch AssertionFailure instead - it is the
subclass actually raised whenever structured data is attached:
import pytest
from assertpy2 import AssertionFailure, assert_that
def test_diff_is_machine_readable():
with pytest.raises(AssertionFailure) as exc_info:
assert_that({"role": "guest"}).is_equal_to({"role": "admin"})
failure = exc_info.value # typed as AssertionFailure
assert_that(failure.diff.kind).is_equal_to("dict")
assert_that(failure.diff.entries[0].path).is_equal_to("role")
The rich diff comes from the fluent form. The == drop-in for matchers (for example
assert response == {"id": match.is_positive()}) hands rendering to pytest instead, which prints its
own dict comparison without the path.
Configuration¶
[tool.pytest.ini_options]
assertpy2_diff = "off" # disable structured diff sections entirely
assertpy2_diff_max_entries = "100" # max entries to show (default 50, 0 = unlimited)
With --color=yes, diffs are colored: red removals, green additions, cyan headers. Entries beyond
the limit are hidden behind a ... and N more entries summary.
Failure and expected exceptions¶
fail()¶
Force a test failure explicitly:
Expected exceptions¶
For a called function, assert it raises and chain assertions on the message:
assert_that(some_func).raises(RuntimeError).when_called_with("foo")
assert_that(some_func).raises(RuntimeError).when_called_with("foo").is_equal_to(
"some err"
)
Or assert it does not raise a given exception:
Tip
For the common "did it raise?" case without inspecting the message, prefer pytest's
pytest.raises context manager.
Beyond the message, the caught exception can be inspected in three more ways.
The exception object - raised() pivots to the caught exception itself, so you can assert on its
type, args, or custom attributes, not only its message string:
err = assert_that(load).raises(ConfigError).when_called_with("bad.toml").raised().value
assert_that(err.code).is_equal_to(42)
The cause chain - caused_by() asserts the exception was chained from a given cause (an explicit
raise ... from, or an exception raised during handling), and has_root_cause() walks to the root of
the chain. Both pivot to that cause's message so the chain continues:
# def save(row): ... raise ServiceError("save failed") from TimeoutError("db timeout")
assert_that(save).raises(ServiceError).when_called_with(row).caused_by(TimeoutError)
assert_that(save).raises(ServiceError).when_called_with(row).has_root_cause(
TimeoutError
).is_equal_to("db timeout")
Exception groups (ExceptionGroup, Python 3.11+, e.g. from an asyncio.TaskGroup) - contains_error()
asserts the caught group contains, recursively, an exception of each given type:
assert_that(run_tasks).raises(ExceptionGroup).when_called_with().contains_error(
ValueError, KeyError
)
Expected warnings¶
For a called function, assert it emits a warning and chain assertions on the warning message:
assert_that(deprecated_func).warns(DeprecationWarning).when_called_with("foo")
assert_that(deprecated_func).warns(DeprecationWarning).when_called_with("foo").matches(
"since 2.6"
)
The category defaults to Warning (matches any warning) and matches subclasses. Or assert it does
not emit a given category:
To also assert on the value the call returned (alongside the warning, or after does_not_warn /
does_not_raise), pivot with returned():
(
assert_that(make_client).warns(DeprecationWarning).when_called_with()
.returned().is_instance_of(Client)
)
(
assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2)
.returned().is_equal_to(3)
)
returned() exposes the type-agnostic core assertions (is_equal_to, is_instance_of, satisfies,
...). It raises TypeError if the call raised (there is no return value to inspect).
Not thread-safe
warns() / does_not_warn() rely on warnings.catch_warnings(), which mutates process-global
state. They are safe within a single thread (including multiple asyncio tasks on one event
loop), but concurrent use across OS threads can interfere - the same limitation as
pytest.warns and unittest.assertWarns.
Custom error messages¶
described_as() prepends a custom label to the failure message:
assert_that(1 + 2).described_as("adding stuff").is_equal_to(2)
# [adding stuff] Expected <3> to be equal to <2>, but was not.
Warnings instead of failures¶
For defensive assertions outside tests, replace assert_that with assert_warn: failures log a
warning instead of raising:
assert_warn() vs warns()
These are unrelated despite the similar names. assert_warn(...) is a soft entry point: the
assertion still checks your value, but logs a warning instead of raising on failure.
assert_that(func).warns(...) is the opposite direction - it asserts that calling func
emits a Python warning.