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 AssertionFailure, assert_that
try:
assert_that(1).is_equal_to(2)
except AssertionFailure as e:
print(e.actual) # 1
print(e.expected) # 2
Catching the subclass is what gives a type checker the structured fields: except AssertionError
still runs, but types e as the base class, which declares none of them.
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 AssertionFailure as e:
assert e.diff is not None # every comparison failure carries one, other failures do not
entry = e.diff.entries[0]
print(entry.path, entry.actual, entry.expected) # b 2 99
Paths a program can follow¶
entry.path is written for a person and cannot be read back. A mapping key goes through str(), so
{3: "a"} and {"3": "a"} produce the same text, and a key holding a dot or a bracket has no grammar
to parse it with.
entry.steps is the same location in the form a program can act on: a tuple of
Step, each holding the key, index, field name, set member or line number
itself, untouched.
from typing import Any
data = {"users": [{"roles": {7: "admin"}}]}
try:
assert_that(data).is_equal_to({"users": [{"roles": {7: "guest"}}]})
except AssertionFailure as e:
assert e.diff is not None
entry = e.diff.entries[0]
print(entry.path) # users[0].roles.7
print([(s.kind, s.value) for s in entry.steps])
# [('key', 'users'), ('index', 0), ('key', 'roles'), ('key', 7)]
cursor: Any = e.actual # `actual` is `object`: the library does not know your payload's shape
for step in entry.steps:
cursor = cursor[step.value]
print(cursor) # admin
An empty steps means the difference is the whole value, which is the entry whose path renders as
.. It is also empty where the path is a label rather than a location: a containment failure reports
missing and extra, and an item that is not in a collection has no position in it.
A sequence whose two sides have shifted apart is the one case where an index alone is ambiguous, since
the index spaces no longer agree. There the step names its side (actual or expected), matching
the actual[2] / expected[1] the path renders.
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
Each .. in that message stands for a run of parts that matched, and stands where that run was, so a
one-field change in a wide object reads as {.., 'b': 2, ..} rather than as both objects in full.
Sequences collapse the same way once they grow past a line or so, which keeps a single changed element out of a forty-item dump:
try:
assert_that(list(range(40))).is_equal_to([*range(27), 999, *range(28, 40)])
except AssertionError as e:
print(e)
# Expected <[.., 27, ..]> to be equal to <[.., 999, ..]>, but was not.
# diff (sequence):
# [27]:
# - 27
# + 999
Sequences are aligned before they are compared, so an element inserted or removed is reported as itself rather than as a mismatch at every index after it:
try:
assert_that([0, *range(1, 40)]).is_equal_to(list(range(1, 40)))
except AssertionError as e:
print(e)
# Expected <[0, ..]> to be equal to <[..]>, but was not.
# diff (sequence):
# actual[0]: - 0
Comparing position by position would have called all forty elements different. A one-sided entry names the sequence its index belongs to, because once the two sides have shifted their index spaces no longer agree.
The alignment is used only where a sequence changed length, and then only where it reads shorter than the plain index comparison. A reversal, a coordinate pair and anything else of unchanged length keep the positional reading, so a comparison that could not benefit does not pay for the attempt. Sequences over a thousand elements are not aligned at all.
A multi-line value is collapsed by line, which matters most: every line of it costs a row of terminal, and the message carries the value twice.
try:
assert_that("line 1\nline 2\nline 3\nline 4").is_equal_to("line 1\nline 2\nline THREE\nline 4")
except AssertionError as e:
print(e)
# Expected <.., line 3: line 3, ..> to be equal to <.., line 3: line THREE, ..>, but was not.
Short values are printed whole, since collapsing them would hide context to save a few characters.
When almost nothing matches there is little to collapse, so the message names the first few differences
and counts the rest as ... and N more.
Either way every difference stays in the diff, so the shorter message loses nothing.
Matcher-based assertions (matches_structure(), satisfies(), each()) attach a DiffResult with
kind='match', where each entry's expected holds the failed predicate's description.
assert_conforms() attaches one of the same kind. A validation failure has an entry per pydantic error.
An exact=True failure has an entry per undeclared field, holding the value the payload sent there with
absent == "expected", or a single entry for the part that cannot be checked, with the reason in
expected. The steps lead into the payload. Two kinds of step hold an object of their own rather than a
key or an index into the one before. item holds a member of a collection that keeps no positions, or the
mapping key a find lies inside. json stands where the value is JSON text (Json[...]) and holds what the
text decodes to, the object the steps after it lead into. A reader takes step.value at both:
def resolve(value, steps):
for step in steps:
value = step.value if step.kind in ("item", "json") else value[step.value]
return value
resolve(payload, entry.steps) is then the very object entry.actual holds, text or not, for a payload
validation left as it was sent. Where a validator changed the payload in place, the payload is returned to
what validation left, so an entry that holds one of its dicts, lists or tuples holds a copy of what was sent
instead. An entry inside JSON text is not copied: it keeps the decoded value reached by following the steps
after its json step.
Under pytest the plugin attaches this same diff to the failure as its own section instead, keeping the
message itself to a single line so the diff is never shown twice. The section travels with the failure,
so the terminal, a JUnit report and an IDE runner such as PyCharm's all show it, and so does a failing
fixture. When failures arrive as an exception group, as two fixtures failing in teardown do, each one
gets its own section, numbered in the order the traceback prints them: Structured Diff (2 of 3). A
soft block's failure gets the sections of each failure it collected, as
What a soft block hands back shows. The
terminal colors it as it draws the failure, when it takes color at all, so a JUnit report, an IDE
runner and a CI log without forced color all get plain text. The plugin is auto-registered through the
pytest11 entry point and needs no configuration.
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 diff sections on the failure.
| 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 on the values the fields and extras hold, not 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, plus the repeat counts for a duplicate failure |
| matcher mismatch | match |
satisfies(), each(), all_satisfy(), any_satisfy(), satisfies_exactly(), zip_satisfies(), matches_structure(), all_fields_satisfy(), assert_conforms(): path + failed predicate |
--- AssertionFailure ---
actual: [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
expected: [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Robert'}]
That section carries the values the assertion named. Every failure holds the value under test on
failure.actual, but most messages open with it, so a contains_key() failure shows the diff without
repeating its subject above it.
For a failed is_equal_to() under ignore= or include= the two rows are the views that were
compared: without the keys left out, and a dataclass or a model as the fields read of it. failure.actual
stays the value you passed. Beside tolerance=, comparators=, strict_types= or ignore_null= the
rows stay the two values.
The diff for that failure, and the other diff shapes, render 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 care about a few fields, extract those and assert on them instead. 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.
bytes and bytearray take that path too, pointed at through their b'...' form:
The carets appear only when the two lines still resemble each other, at difflib's own 0.75 similarity cutoff.
Past that the two lines are printed plainly. A guide underlining most of the line says nothing the pair of values does not already say.
Set and contains show extra items in red and missing items in green:
Match (the matcher-driven assertions listed in the table above) shows each field's path and the
predicate that failed, with the actual value in red. A field assert_conforms(..., exact=True) found
undeclared has no predicate, and is shown as path: - value.
Every mismatch is listed, not just the first. There is no green, since a predicate has no "addition".
Nested structures are diffed recursively and report the exact path to the differing value (for example
[1].name). A value that leads back into itself is read as far as the two sides differ, and the way back adds
no row. Where == holds two values apart and nothing inside them differs, as with two instances of a class
that compares by identity, the row is the pair itself.
Two values can render to the same text and still not be equal, most often because they differ only in type.
The message then 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 covers every walker this library owns:
- the diff rendering, and the comparison under an option (
ignore,include,strict_types,tolerance,comparators), which treat a pair met again inside itself as equal rather than recursing, so two values are compared as the trees they unfold into. A key option applies at its own level of a cyclic value as it does of a flat one:ignore="id"leaves out theidof the value compared, not theidof what it leads back to - a contract snapshot, which records a revisited node as
<circular ref> - a value snapshot, which fails with a message naming the cycle, since json cannot represent one
The bare equality check is not ours: it follows Python's own ==, so two structurally equal
cyclic graphs raise RecursionError exactly as a plain assert a == b would.
The comparison settings are named back to you¶
ignore and include appear inside the failure sentence. The rest of the comparison settings get a
line of their own, so a failure under tolerance, comparators, ignore_null or strict_types
says which of them were in force:
from assertpy2 import assert_that
try:
assert_that({"price": 1.0}).is_equal_to({"price": 9.0}, tolerance=0.001, strict_types=True)
except AssertionError as failure:
print(str(failure).splitlines()[1])
# compared with tolerance=0.001, strict_types=True
Nothing is added on an ordinary failure, where every setting sits at its default. The line goes
below the sentence rather than into it, so the original text stays a prefix and a match= written
against it keeps matching.
Some failures say why, not only what¶
A diff says what differs. There are failures where that leaves the hard part undone: two strings that render identically and differ in a trailing space, or a comparison that could never have passed because a NaN was in it. Where the whole difference has one explanation, it gets a line:
from assertpy2 import assert_that
try:
assert_that({"user": {"name": "bob "}}).is_equal_to({"user": {"name": "bob"}})
except AssertionError as failure:
print(str(failure).splitlines()[1])
# every difference here is one of surrounding whitespace
The line appears only when it accounts for every entry in the diff. A half-explanation is worse than none: you would act on it and land back at the same failure. So a value that is simply wrong gets nothing, and neither does a failure where one field differs by whitespace and another by its content.
The one a JSON payload produces more than any other is a field that came back as text. The diff shows
- 7 against + '7' on every row, and the line says it once:
from assertpy2 import assert_that
payload = {"id": 7, "quantity": 3}
try:
assert_that(payload).is_equal_to({"id": "7", "quantity": "3"})
except AssertionError as failure:
print(str(failure).splitlines()[1])
# every difference here is the same text against a value of another type
A single value compared on its own says it in the sentence instead, tagging each side with its type
(Expected <7:int> to be equal to <7:str>), so the line stays away rather than repeating it.
NaN is stated first whatever else differs, since no value on the other side could have made that
comparison pass:
from assertpy2 import assert_that
try:
assert_that(float("nan")).is_equal_to(float("nan"))
except AssertionError as failure:
print(str(failure).splitlines()[1])
# a NaN takes part in this comparison, and a NaN is equal to nothing, not even itself
The other failure that leaves a reader stuck is two values that print the same and are not equal. A
class that defines no __eq__ compares by identity, so an expected value your test built is never
equal to the one the code returned, however well they agree:
from assertpy2 import assert_that
class Order:
def __init__(self, id, total):
self.id, self.total = id, total
try:
assert_that(Order(7, 10.0)).is_equal_to(Order(7, 10.0))
except AssertionError as failure:
print(str(failure).splitlines()[1])
# these values compare with object's __eq__, so equality is identity and no two separate instances are equal
It is said before anything about the values, for the same reason NaN is. No value on the other side
would have made the comparison pass, so a line about how the two differ would send you to fix something
that cannot help.
Exceptions are the everyday case, since they carry identity equality too. Your own comparators= entry
over those values silences it, the verdict there being your predicate's rather than the type's.
The same holds one level down. A row of the diff whose two sides print the same is never left to stand alone. Two sides of two classes are each printed with its class, and with its module where the classes share a name:
from assertpy2 import assert_that
class Kept(dict):
pass
try:
assert_that({"a": Kept(x=1)}).is_equal_to({"a": {"x": 1}}, strict_types=True)
except AssertionError as failure:
print("\n".join(str(failure).splitlines()[-2:]))
# - {'x': 1}:Kept
# + {'x': 1}:dict
Two sides of one class that print the same get a line instead. Where the class leaves __eq__ to
object and no comparators= took part, the line says so of the row: a difference here prints the same
on both sides, and the class of the two leaves __eq__ to object, so two separate instances are never
equal. Anywhere else it says what is known, that the repr does not show the difference, and lists the
attributes of the two that differ:
from assertpy2 import assert_that
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __eq__(self, other):
return (self.amount, self.currency) == (other.amount, other.currency)
def __repr__(self):
return f"Money({self.amount})"
try:
assert_that({"total": Money(5, "EUR")}).is_equal_to({"total": Money(5, "USD")})
except AssertionError as failure:
print(str(failure).splitlines()[1])
# a difference here prints the same on both sides, so what holds the two apart is not in their repr (attributes that differ: currency)
Where the differences repeat down a sequence, a line says where they sit. A place is the path with
every position written [*], and every entry of the diff is counted, the ones past the fiftieth that are
not printed included:
from assertpy2 import assert_that
rows = [{"id": n, "name": f"user{n}", "updated_at": f"10:00:{n:02d}"} for n in range(51)]
other = [dict(row, updated_at=f"11:00:{n:02d}") for n, row in enumerate(rows)]
other[50]["name"] = "renamed"
try:
assert_that(rows).is_equal_to(other)
except AssertionError as failure:
print(str(failure).splitlines()[1])
# the 52 differences here are at <[*].updated_at> (51) and <[*].name> (1)
It is said for three or more differences at one to three places, under whatever line says why. It names
where and recommends nothing: whether updated_at belongs under ignore= is your call.
A failed contains() is explained the same way, and so is every assertion that looks for something.
Where one fact about the item not found accounts for it, a line says so in the words of the assertion
that failed: the item is a NaN, a candidate reads the same and is of another plain type, or a candidate
prints the same and their class compares by identity. That holds for contains(), contains_key(),
contains_only(), contains_exactly(), contains_exactly_in_any_order(), contains_only_once(),
contains_sequence(), contains_in_order(), is_in(), is_subset_of(), contains_value() and
contains_entry().
All of them but the two that walk in order, the keys and the entry also name the closest row with what differs, where a row shares a key with the item: for dicts and for dataclasses, attrs instances, named tuples and models. Both are said of one item not found. Several would each need their own.
Where the thing not found is a key, and the value holds one that reads almost the same, the line names
it. contains_key(), contains() on a mapping, contains_entry() and has_<name>() say it, the
last of an attribute as well:
Expected <{'user_id': 2, 'email': 'a@b'}> to contain key <userId>, but did not.
a key reads the same but for how its words are joined: <user_id>
Three things are looked for, the narrowest first. A key is the same once both are lowercased. A key is
the same once both are lowercased and what joins their words is taken out (a space, an underscore, a
dot, a hyphen), userId beside user_id. One spelling alone is almost the same: two neighbouring
letters swapped, a letter too many or too few, or the two nearly one text, a difflib ratio of 0.85 once
lowercased and unjoined, which a long name reaches with a few letters dropped or replaced. The first two
are facts. The third is a likeness and is worded as one. Where two spellings come that close nothing is
said, and keys that differ only in case or joining are one spelling, named together: the first three,
and how many more there are.
The line is said of one key not found: where several are missing none is named. Only keys that are plain
text are read, and of an object only the public names it holds. The likeness is looked for among names
of a hundred characters at most. A has_<name>() asked under not_ for
a name that is not there fails as it does without not_, the line included. Over the keys of three
real payloads, a key asked for with one slip was named in 99.3% of 2529 cases, and no other key ever was.
contains_exactly() and contains_exactly_in_any_order() count. Where everything missing is one item
the value does hold, the failure says the two counts of that item instead:
<'7'> is held 1 time and was asked for 2 times. With nothing missing, where everything left over is
one item that was asked for, it says the two counts of that one.
import json
from assertpy2 import assert_that
payload = json.loads('{"ids": [7, 8]}')
try:
assert_that(payload["ids"]).contains("7")
except AssertionError as failure:
print(str(failure).splitlines()[1])
# an element reads the same as the item not found and is of another type: int, not str
try:
assert_that("7").is_in(*payload["ids"])
except AssertionError as failure:
print(str(failure).splitlines()[1])
# a given item reads the same as the value and is of another type: int, not str
A failed contains_entry() says what the key holds, or that the key is not there. A failed
has_<name>() fails as is_equal_to() on the value it read: the failure carries that value, the
operand and their diff, and two values that print the same are told apart by class. Two dicts are
printed as is_equal_to() prints them, the keys that match collapsed to .., and so are two lists or
two plain tuples too long to print whole, unless the two collapsed texts read alike or the values cannot
be compared a second time: then both are printed as texts, cut around the place they part. Where the
two cannot be read a second time the diff is one entry, the pair itself.
A text held against a text is explained the same way. Where the relation asked holds once one thing is set aside, the line names that thing:
Expected <Total: 42 EUR> to contain item <Total: 42>, but did not.
the value holds it once every run of whitespace is read as one space
The space after Total: in the value is a no-break space, which the sentence cannot show.
starts_with() and ends_with() name whitespace at that end of the value (a single \n, \r\n or
\r is called a line break), case, whitespace at an end of the text given, or that the value holds the
text somewhere else.
contains() on a text names case, or that every run of whitespace has to be read as one space, which
is what a line break, a tab, a no-break space and two spaces in a row all come to, or both.
starts_with_ignoring_case(), ends_with_ignoring_case() and contains_ignoring_case() say all of
that but case. is_equal_to_ignoring_case() names the whitespace that still holds the two apart, and
is_equal_to_ignoring_whitespace() names case. For two texts is_equal_to() named line endings and
surrounding whitespace before: it names the kind or amount of whitespace too, past those two.
One fact is said, the narrowest. Two at once get no line, a leading space and another case for
starts_with(): only contains() has a line for case and whitespace together, and equality one for
line endings and surrounding whitespace. These lines are said of plain text alone: a str of a
class of its own, bytes and the first element of a list get none. is_equal_to() is the exception,
and reads bytes and a str of any class as it did before.
Two moments say how far apart they are. is_before(), is_after() and their _or_equal_to twins
give the distance on the other side, or say that the two are the same moment:
Expected <2026-01-01 12:00:00+00:00> to be before <2026-01-01 13:30:00+02:00>, but was not.
the value is 0:30:00 after the moment given
is_close_to() and is_not_close_to() on two datetimes give the distance and how far it is from the
tolerance:
Expected <2026-01-01 12:00:00> to be close to <2026-01-01 12:00:07> within tolerance <0:00:05>, but was not.
the two are 0:00:07 apart, 0:00:02 more than the tolerance
The distance is datetime's own subtraction, exact to the microsecond, printed as a timedelta prints.
It is said where datetime itself made the verdict. A subclass that writes its own comparison, a pandas
Timestamp for one, gets no line, and one that writes its own - or +, as pendulum's does, gets the
line of the four ordering assertions and not the one of closeness. The tolerance has to be exactly a
timedelta.
is_less_than(), is_greater_than() and their _or_equal_to forms, which take two datetimes as well,
say of them what is_before() and its kin say. is_between() says nothing.
Two datetimes that share one zone object are compared by Python on their wall clocks, which is the time between the two only while the zone keeps one offset. Across a change of its clocks it is not, and the two readings of a repeated hour compare equal. The line then speaks of the clock and not of the moments:
Expected <2026-10-25 02:30:00+02:00> to be before <2026-10-25 02:30:00+01:00>, but was not.
the two read the same on the clock they share
Two in different zones are compared as the moments they are, and the line gives the time between them.
The line itself asks a zone for its offset only where the zone is datetime.timezone or
zoneinfo.ZoneInfo. With a zone of another library, pytz's or dateutil's, two that share it get the
line about their clock, and two in different zones get none.
A third line appears when the value came from an HTTP response, naming the request it answered:
Expected <500> to be equal to <200> on attribute <status_code>, but was not.
from GET https://api.example.com/orders/7 -> 500
It survives the step into the body, so a failure under
decoded_as_json() still says which call produced it. It
reads no body and starts no I/O, which is why a streaming response that has never been read is safe to
assert on.
Like the settings echo above, these sit below the sentence, so anything written against the original message keeps working.
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.
The comparison window of an IDE¶
pytest calls the hook pytest_assertrepr_compare for a failed assert left == right, and a test
runner that opens a two-pane comparison listens to it. A failed is_equal_to() is the same comparison,
so the plugin hands the two values it compared to those listeners, the value under test as left and
the operand as right.
PyCharm's runner is one of them. Run from PyCharm, a failed assert_that(actual).is_equal_to(expected)
gets the "Click to see difference" link a plain assert actual == expected gets. The window it opens
shows the operand under Expected and the value under test under Actual, both whole. Seen in
PyCharm 2026.3 on a response of forty nested rows. As for a plain assert, PyCharm also prints the two
values above the link.
What is handed over is the pair the assertion compared:
| Failed assertion | The two panes show |
|---|---|
is_equal_to() |
the value and the operand |
is_equal_to() under ignore= or include= |
the two copies without the keys left out, so a key the assertion passed over is in neither pane |
has_<name>() |
the value it read and the operand |
A record in such a copy, a dataclass, an attrs class or a model, is handed over as the dict of its
fields, which is what was compared. A pane then lays a record out line by line, and a dict beside a
record reads in the same shape. That holds for a mapping whose keys are each exactly a str, an int,
a bool, a float, a bytes or None. A mapping with any other key, one of a subclass of those
among them, is handed over as it was.
The window compares two texts, as it does for a plain assert:
- two values
==holds equal that print differently,1andTrue, show there as a difference - two that differ and print the same, two NaNs, show as none
The failure's own diff is the one built from the comparison.
Nothing is handed over in three cases:
- under
tolerance=,comparators=,strict_types=orignore_null=, where a text comparison of two values would show a difference the assertion let pass, or none where it found one - for a negation, a membership assertion or a
soft_assertions()block - for a group of several failures
The failure is what it was there: its message, failure.diff and the report sections do not change.
Who hears the hook:
- It is called while the report of the test is being made, before it is logged. A listener that reads the pair when the report is logged hears it, which is how PyCharm's runner reads.
- Under
pytest-xdistit is called in the worker, and a runner that reports from the controller does not hear it. assertpy2_comparison = "off"turns the hand-over off.
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 every failure is raised as, including a soft block's aggregate and fail(), so one handler
covers the library:
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
diff = failure.diff
assert diff is not None # optional on the class, present on every comparison failure
assert_that(diff.kind).is_equal_to("dict")
assert_that(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.
What the failures had in common¶
Forty failing tests are usually not forty problems. Where three or more of them differ at the same place, the run ends with a line saying where:
assertpy2 failure clusters:
37 of 40 failing tests differ at user.role
actual: 'superadmin'
expected: 'admin'
3 of 40 outside any cluster of 3
You get that without configuring anything. Three is the floor rather than a rule, and it moves:
[tool.pytest.ini_options]
assertpy2_failure_clusters = "5" # failing tests a cluster must hold before it is printed, or "off"
That is a hypothesis you can act on before opening a single traceback, and it comes from the same
structured diff the report sections are built from. Nothing about it is a guess: two failures share a
cluster when their difference is equal, never when it looks similar. Where a difference has no place to
be keyed on, what is compared is the text the values print as, so two values with the same repr count
as one.
Failures are grouped by the place they differ at, not by the values they showed. A broken constant gives every failure the same pair, and grouping on values would find it. A broken formula differs at the same field with a value of its own per test, and grouping on values would scatter it into forty clusters of one. Where a cluster's values disagree, the summary says so rather than printing the first pair as though it explained all of them:
A row index is generalised away, so users[0].role and users[7].role are one difference reported
against two rows. A line number in a string comparison is generalised the same way.
What the summary will and will not claim:
- Three kinds of difference carry no location. A containment failure names
missingorextra, a set entry names a member, and a scalar failure is the whole value. Those group on the failure's own diagnostic line when it has one, so five uploads differing from their expected bodies only by a trailing newline read as one cause across five different payloads. They print as5 of 5 failing tests share one scalar difference, because a family is not a place and a summary pointing at one would be inventing it. - Containment and set differences without a diagnostic stay out of it. Their two fields hold
presence rather than values, so a missing item reports
Noneon the actual side, and a heading built from that would tell you your value wasNonewhen it was a list of three. - The floor is a count, never a share of the run. Under a share, every additional cause raises the bar for all the others, and a run of forty failures splitting cleanly into five causes of eight would say nothing at all.
- The share is measured against every test that went red, errors from a broken fixture included, so
10 of 13 outside any cluster of 3is the honest reading of a run that was mostly environment. It names the floor because that is what it measures: two tests sharing a difference under a floor of three are related, and the summary declined to print them rather than found nothing. - Tests, not reports. A test that fails its assertion and then errors in teardown is one broken test, and pytest counts it in both of its own totals.
A collection that failed is red and is not a test, so it is named on its own line rather than folded into
a count of tests: 1 collection error, not counted below. That only happens under
--continue-on-collection-errors, since otherwise the run stops before anything is summarised.
Under pytest-xdist, a worker killed mid-run never ships what it recorded, and a worker running a
different version of this library can ship something unreadable. Either way the summary says so rather
than presenting a share of what it happened to receive:
assertpy2 failure clusters:
1 worker died, so these counts cover only what was reported
6 of 6 failing tests differ at user.role
Everything printed is bounded, because a diagnostic that grows with the run hurts the worst runs most:
- values longer than 200 characters are cut
- a cluster reports a floor rather than a count past 64 distinct values per side (
and 64+ other values) - at most five clusters print, followed by a count of the rest
What a cluster is keyed on is not cut, since two payloads agreeing for their first 200 characters are still two failures. The failure message and the structured diff keep their values whole.
Nothing here can fail a run that would otherwise have passed or reported. If the summary cannot be built, it says so in a warning and the run's own results are untouched.
A run that passes pays nothing for it, since there is no failure to record. A run that fails walks each failed report carrying a diff once, measured at 2.3 µs over one differing entry and 0.11 ms over fifty, and builds the summary once for the whole run, 15 µs over forty failing tests. Turned off, the recorder returns on the configuration alone and nothing is read at all.
Configuration¶
Two of the settings below guard against a test that passes without checking anything: the dangling detector and the vacuous-quantifier warning. Both are off unless you ask, because either can turn a green run red, and a suite that inherited this library did not ask for that. A new suite gets both in one line:
[tool.pytest.ini_options]
assertpy2_profile = "safe" # dangling on, vacuous on (default "compatible": both off)
Naming a setting yourself wins over the profile, so safe plus assertpy2_dangling = "off" is a suite
that wants the other and has said so where the next reader will look.
Failure clustering is not one of them and needs no profile. It reads a run that has already gone red,
so there is no green run for it to change. Write assertpy2_failure_clusters = "off" to turn it off.
Seeing them, and being stopped by them¶
safe warns. A run full of findings still exits zero, which is a fair first look and a poor gate. The
exception is a suite that escalates warnings: under filterwarnings = ["error"] each finding turns its
test red, as strict would.
strict turns on the same two guards and fails the tests they find:
The two read differently in the report, because they are found at different moments:
ERROR test_orders.py::test_the_total DanglingAssertionWarning
FAILED test_orders.py::test_every_item VacuousAssertionWarning
A dangling chain is found while the module is collected and reported when its test is set up, so the test never runs and pytest calls it an error. A vacuous pass is found while the assertion itself runs, so it is a failure. Both stop the run.
The escalation is scoped to this library's own two warnings. A DeprecationWarning from somewhere
else in your suite is left exactly as it was, which a blanket filterwarnings = "error" would not do.
Failure clustering is not escalated and cannot be: it reads a run that already went red.
[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)
assertpy2_comparison = "off" # do not hand a failed equality to comparison listeners
assertpy2_poll_report = "off" # silence the near-timeout poll report (default 0.7)
assertpy2_failure_clusters = "off" # stop grouping failures that share one difference (default 3)
assertpy2_dangling = "on" # warn about assert_that() statements that assert nothing
assertpy2_dangling_entries = "check" # your own assert_that wrappers, for the check above
assertpy2_vacuous = "on" # warn when a universal assertion passes over an empty value
In a terminal that takes color, or with --color=yes, diffs are colored on screen: red removals, green
additions, cyan headers. Every other reader of the failure gets the same diff as plain text. Entries beyond
the limit are hidden behind a ... and N more entries summary.
The plugin also adds assertpy2_allure (see Allure) and these
command-line flags, each documented where it is used:
| Flag | What it does |
|---|---|
--assertpy2-vacuous |
Warn when a universal assertion passes over an empty value, or is_equal_to compares nothing |
--assertpy2-dangling |
Warn when assert_that() is written as a statement that asserts nothing |
--assertpy2-snapshot-update |
Overwrite failing snapshots with the current values |
--assertpy2-snapshot-ci |
Fail instead of creating a missing snapshot (auto-enabled on CI) |
--assertpy2-snapshot-no-ci |
Disable CI mode and its autodetection |
What a failure exposes¶
An assertion library's job is to put the values it compared in front of you, so a failing assertion is a deliberate disclosure.
That output does not stay in your terminal. It reaches CI logs, report attachments and, for snapshots, files committed to the repository. Below is what travels where, so you can decide what to hand the library in the first place.
assertpy2 caps size. It never redacts. Nothing here inspects a value to decide whether it looks like a credential, and nothing ever will: a masker that guesses wrong in the safe direction hides the difference you were debugging, and one that guesses wrong in the other direction is worse than no masker at all.
The channels¶
| Channel | Carries | Reaches |
|---|---|---|
the message, str(failure) |
the differing values, capped | pytest output, CI logs, anything catching AssertionError |
failure.actual / failure.expected |
the whole untouched values | the pytest report section, Allure attachments, your own except block |
failure.diff |
per-path actual and expected, capped when rendered | the same |
| a snapshot file | the serialised value | __snapshots/*.json, normally committed |
The caps are on rendering only. The value under test, a value it is held against, a pattern and a list of items are printed whole up to 4000 characters in every assertion. Past that the message says how much it left out, and what it keeps depends on what the assertion is about:
| What a message prints | What it keeps past the cap |
|---|---|
the two sides of is_equal_to |
the first 4000 characters, with the rest left to its diff |
two texts held against each other: starts_with, ends_with, the comparisons that ignore case or whitespace |
a cut around the place they part |
| anything else | its first 3000 and last 1000 |
| a name or a count you pass: a key, a class, a file, a length | all of it, as given |
A diff has caps of its own:
| Part of a diff | Cap |
|---|---|
| a row | 400 characters |
a row a soft_assertions() block keeps |
4000 a side |
| the whole block | 20 KB |
| matching parts of a structure | collapse to .. |
None of that shrinks what failure.actual holds, and failure.actual is what a reporting integration
serialises.
Under pytest -vv a message and the values of the report section are printed whole, as pytest prints
its own output there. Under ignore= or include= alone those are the two views compared, whole. The
rows of a diff stay cut.
Keeping a value out¶
Excluding a key from the comparison also excludes it from the message and the diff:
from assertpy2 import AssertionFailure, assert_that
payload = {"user": "alice", "api_key": "sk-live-9f3b2a7c", "n": 1}
expected = {"user": "alice", "api_key": "sk-live-different", "n": 2}
try:
assert_that(payload).is_equal_to(expected, ignore="api_key")
except AssertionFailure as failure:
print("sk-live" in str(failure)) # False: the key was never compared, so it is not reported
print("sk-live" in str(failure.actual)) # True: the object you passed is unchanged
ignore and include govern the report as well as the verdict, so the value stays out of the log.
The exception still holds the original objects, because they are yours and the library does not copy
them.
For anything stronger, redact before asserting rather than after. Compare a filtered copy, or wrap the
value in a type whose __repr__ prints a placeholder: both put the decision where the meaning is
known, which is your code and not ours.
Turning channels off¶
assertpy2_diff = "off" drops the structured diff section from pytest reports, and
assertpy2_allure = "off" stops attaching values to Allure. Both leave the message itself intact, so
neither is a substitute for not passing the value in.
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)
A type checker reads err as the ConfigError that raises() named. After caused_by(), has_root_cause()
or error_of() it reads the type that step asked for, since that is the exception the chain moved to.
The cause chain. caused_by() asserts the exception was chained from a given cause, either an
explicit raise ... from or one raised during handling.
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")
Either pivot also takes a union or a tuple of classes, as isinstance does, and holds when the cause is any
one of them: caused_by(TimeoutError | ConnectionError). With no single class named, a type checker reads the
cause as BaseException (Pyrefly keeps a union as the union).
Exception groups (ExceptionGroup, Python 3.11+, e.g. from an asyncio.TaskGroup). contains_error()
asserts the caught group holds an exception of each given type, and does_not_contain_error() asserts it
holds none of them. Both search the whole tree, so a group nested inside a group is reached.
To ask about the failures rather than their types, pivot. errors() hands back the leaves as a list, so
every collection assertion applies to them, and error_of() picks the first exception of a type and
continues on its message, with the object itself still one raised() away:
def run_tasks():
raise ExceptionGroup("2 tasks failed", [ValueError("bad id"), KeyError("missing")])
caught = assert_that(run_tasks).raises(ExceptionGroup).when_called_with()
caught.contains_error(ValueError, KeyError)
caught.does_not_contain_error(TimeoutError)
caught.errors().is_length(2)
caught.error_of(ValueError).contains("bad id")
errors() flattens nesting, so a count is over the failures themselves and not over the shape the group
happened to take. contains_error() and error_of() search the wider set, groups included, so the two
always agree about whether a type is in there.
To assert the shape itself, matches_error_tree() takes one entry per member and a list for a nested
group. Nothing may be left over on either side, and a plain list of types is the only spelling of a
subgroup, since a tuple already means "any of these" elsewhere in the library.
Only a nested list looks inside a subgroup. A type entry matches a subgroup node the same way it matches
a leaf, by isinstance, so matches_error_tree(Exception) holds for a group of one member whatever that
member turns out to hold.
def run_tasks():
raise ExceptionGroup("failed", [ValueError("bad id"), ExceptionGroup("retries", [KeyError("missing")])])
caught = assert_that(run_tasks).raises(ExceptionGroup).when_called_with()
caught.contains_error(ValueError, KeyError)
caught.matches_error_tree(ValueError, [KeyError])
contains_error() passes there whatever the nesting is. matches_error_tree() is the one that would
notice KeyError moving out of retries.
Entries pair with members rather than being read in order, so the same claim written either way round
gets the same verdict. matches_error_tree(Exception, ValueError) and matches_error_tree(ValueError,
Exception) both hold for a group of one ValueError and one 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() hands back a builder over the value the call returned, so that value's own assertions
apply: contains() on a returned list, starts_with() on a returned string. A type checker reads its
.value as the callable's declared return type. The builder is the whole one rather than the view
assert_that() would pick for that type, so a checker accepts any assertion on it, and one the value
cannot answer raises at run time. returned() 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.
Concurrent use across OS threads can interfere, the same limitation pytest.warns and
unittest.assertWarns carry.
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.
What a soft block hands back¶
A soft_assertions() block renders its collected failures into one
numbered message. That message is a rendering, not the only copy: the raised AssertionFailure also
carries failures, one AssertionOutcome per collected failure,
in the order they were collected.
from assertpy2 import AssertionFailure, assert_that, soft_assertions
try:
with soft_assertions():
assert_that({"role": "guest"}).is_equal_to({"role": "admin"})
assert_that("foo").is_length(4)
except AssertionFailure as e:
print(len(e.failures)) # 2
first_diff = e.failures[0].diff
assert first_diff is not None
print(first_diff.entries[0].path) # role
print(e.failures[1].message) # Expected <foo> to be of length <4>, but was <3>.
Each record keeps what the text had flattened: the values, the diff, the group label it was collected
under, and the (file, line) the message renders in brackets. A polling assertion that times out inside
a soft block keeps its trace there too.
Off pytest the message is the only place a reader finds the diffs, so each entry carries its diff in the
block form a single failure prints. For the block above, str(e) reads:
soft assertion failures:
1. Expected <{'role': 'guest'}> to be equal to <{'role': 'admin'}>, but was not. [test_app.py:6]
diff (dict):
role:
- 'guest'
+ 'admin'
2. Expected <foo> to be of length <4>, but was <3>. [test_app.py:7]
One message has room for the blocks of twenty failures and 60 000 characters. A failure past that keeps
the compact form, one line per differing path, and the last line of the message names it. A block too
wide for what is left keeps the compact form too and takes one of the twenty, but none of the characters,
so a smaller block after it still fits. Every block is a print away:
print(e.failures[20].diff) renders it. diff is None on a failure that has none, such as
is_true() on False. An exception of yours that escapes the block gets the same text as a note.
Under pytest the message keeps the compact form, and the plugin hangs the blocks on the report. Each
collected failure gets the sections a raised failure gets, numbered as the message numbers it,
Structured Diff (failure 2 of 3), and each section opens on that failure's line of the message, with its
group in brackets.
Under ignore= or include= a section shows the two copies the assertion compared, as it does for a
raised failure. The room is the same, twenty failures and 60 000 characters, shared by every soft block
that fails in one report. Each block with failures left out gets one more section naming them, and a
failure with nothing to show takes no place among the twenty. An exception of yours that escapes the block brings along what
the block had collected before it, and those failures get their sections too.
In the compact form, what each kind of value adds:
- a scalar, or a short line of text, adds no line at all, since the entry above already carries both values in full
- a long line adds one, cut to a window around the first difference, which is the part nobody finds by reading a 200-character payload twice
failures is empty on every other failure, which is about one value rather than a collection of them.
Asking instead of asserting¶
check() runs the next assertion for its verdict and hands back an
AssertionOutcome instead of raising. It is truthy when the
assertion held, and carries the message, values and diff when it did not.
outcome = assert_that({"role": "guest"}).check().is_equal_to({"role": "admin"})
print(bool(outcome)) # False
print(outcome.message) # Expected <{'role': 'guest'}> to be equal to <{'role': 'admin'}>, but was not.
assert outcome.diff is not None
print(outcome.diff.entries[0].path) # role
Use it where a test is not what you are writing: branching on a precondition, or reporting a check into something that is not pytest. An assertion states a requirement, and stopping at the first unmet one is the point of the other modes.
Negation is proxied, so assert_that(5).check().not_.is_positive() answers too. A bad argument still
raises: TypeError and ValueError mean the call itself is wrong, which is not a verdict about the
value.
Unlike a failure collected by soft_assertions() or logged by assert_warn(), a failed check does not
mark the value as unverified: .value keeps working, because a question was asked, not an assertion
made.
What was asked, as data¶
message says what failed in English, and actual, expected and diff say what the values were.
None of them says what was asked of the value, and expected cannot, since what it holds changes
with the assertion:
| Assertion | expected holds |
|---|---|
is_equal_to(2) |
the operand |
contains(2) |
a tuple |
satisfies(...) |
a rendered description |
is_instance_of(str) |
a type |
is_empty() |
nothing at all |
requirement answers that in one shape, on every failure, whichever mode delivered it:
outcome = assert_that(1).check().is_close_to(9, 0.1)
assert outcome.requirement is not None
print(outcome.requirement.operation) # is_close_to
print(outcome.requirement.parameters) # {'other': 9, 'tolerance': 0.1}
print(outcome.requirement.negated) # False
The same three fields are on the raised
AssertionFailure, on each entry of a soft block's failures,
and in the Allure attachment. That is what lets a report group failures without parsing messages: every
is_close_to failure in a run carries the same operation, and a negated one is marked rather than
worded differently.
Two details worth knowing before you build on it:
- Parameters are the values the assertion ran with, keyed by its own parameter names. One left out
appears with its default, and
is_close_to(9, 0.1)reads the same asis_close_to(other=9, tolerance=0.1). - An assertion that delegates answers its own name:
is_positive()asksis_greater_than(0)underneath, and reportsis_positive.
requirement is None where the failure is not one assertion's, and that is a limit rather than a
gap:
| Failure | Its requirement |
|---|---|
fail(), soft_fail(), a bare error() carrying a message of your own |
None, since they name no operation |
| a precondition of one of the few members that assert nothing on their own | None, since it is about the shape of the value rather than about a requirement you stated |
what a soft block or assert_all raises for everything it collected |
None, with the answer on each entry in its failures |
| a polling timeout | that of whatever its last attempt raised: usually the assertion that kept failing, and None when the probe raised something that is not one of this library's failures, such as an exception it was told to ignore or a bare AssertionError |
Reading it costs a built-in assertion nothing when it passes, since nothing runs until a failure is composed. A registered extension is the exception: it says what it is at the call, so it pays a wrapper whether it passes or fails.
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(...) goes the opposite way. It asserts that calling func emits a
Python warning.
Warning output and custom logger
Pass your own logger for custom formatting:
The assertpy2 logger carries its own handler and also propagates, so if your suite calls
logging.basicConfig() each warning is printed twice. Turning propagation off silences the
duplicate:
That trade is yours to make: propagation is also how pytest's caplog fixture receives these
records, so turning it off stops you asserting on them.
Handing each failure to your own code¶
on_failure takes a callable and hands it each failure as its
AssertionOutcome instead of logging it: the message, the values,
the diff, the requirement and the (file, line) the assertion was written
at. A logger that should carry the record, diff included, takes it from there:
import logging
from assertpy2 import assert_warn
log = logging.getLogger("checks")
def report(outcome):
text = outcome.message if outcome.diff is None else f"{outcome.message}\n{outcome.diff}"
log.warning(text, extra={"assertpy2_outcome": outcome})
assert_warn({"status": 500}, on_failure=report).is_equal_to({"status": 200})
Every link of a chain is handed over, a pivot and extracting() included, and a polling timeout comes
with its trace. The value is marked as unverified before your callable
runs, as it is when a failure is logged. An exception your callable raises comes out of the assertion
that failed, as one a logger raises does, an AssertionError included. on_failure takes the place of logger, and
passing both is a TypeError. Through a LoggerAdapter the record keeps your extra only with
merge_extra=True, which arrived in Python 3.13.