Skip to content

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:

Colored sequence diff: [1].name with the removal in red and the addition in green

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:

Colored string diff: the changed word marked with difflib carets, removal in red and addition in green

Set and contains show extra items in red and missing items in green:

Colored set diff: extra items in red, 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"):

Colored match diff: each field's path, the failed predicate, and the actual value in red

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:

from assertpy2 import fail

fail("forced failure")

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:

assert_that(safe_func).does_not_raise(ValueError).when_called_with("foo")

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:

assert_that(safe_func).does_not_warn(DeprecationWarning).when_called_with("foo")

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("foo").is_length(4)   # logs a warning, does not raise

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.

Warning output and custom logger
2019-10-27 20:00:35 WARNING [app.py:42]: Expected <foo> to be of length <4>, but was <3>.

Pass your own logger for custom formatting:

assert_warn("foo", logger=my_logger).is_length(4)