Patterns & recipes¶
Task-oriented recipes for common testing jobs. Each one is a small, copy-pasteable pattern. Follow the links into the guides and API reference for the full surface.
Test an HTTP API response¶
Assert the status, then validate the body's shape so dynamic fields (ids, timestamps) never make the test brittle. Use a matcher per volatile field and a literal for the fixed ones:
resp = client.get("/orders/42")
assert_that(resp).has_status_code(200)
assert_that(resp).decoded_as_json().matches_structure({
"id": match.is_positive(),
"status": match.is_in("paid", "pending", "shipped"),
"customer": {"name": match.is_non_empty_string()},
})
Assert on the response rather than on resp.status_code and resp.json(), and every failure below
says which response it came from, with the request line where the client kept one:
Expected <{'id': 42, 'status': 'refunded'}> to match structure ..., but at <status>: ...
from GET https://api.example.com/orders/42 -> 200
That line reads no body and starts no I/O, so a streaming response that has not been read is safe to assert on.
decoded_as_json() is where the body gets read, because calling it is you asking for it. When the body
is not JSON, the failure names the content type and how the body starts, rather than handing you a
JSONDecodeError from your own test. A body that could not be read at all is reported as exactly that,
since an unread body may well have been JSON:
assert_that(resp).decoded_as_json()
# ValueError: the response body is not JSON: content-type is 'text/html; charset=utf-8'
# and it starts with '<!doctype html><html><body>Login required</body></html>'
It works on any client that keeps a status code and headers. The suite exercises six of them against the real library rather than a stand-in: httpx, httpx2, requests, Flask, Django and Starlette, whose test client is what FastAPI's is. None of them is imported, and none has to be installed.
The body check itself is an ordinary value assertion, so it runs without a live server:
body = {"id": 42, "status": "paid", "customer": {"name": "Alice"}}
assert_that(body).matches_structure({
"id": match.is_positive(),
"status": match.is_in("paid", "pending", "shipped"),
"customer": {"name": match.is_non_empty_string()},
})
When you have an OpenAPI spec, assert the whole body against the operation's response schema with
conforms_to_openapi(). With a Pydantic model, reach for
assert_conforms(), which also
narrows the chain to the model's type.
Ignore volatile fields (ids, timestamps)¶
For a full-value comparison where a few fields change every run, name them with placeholders
(asserted separately by a matcher) or drop them with ignore:
actual = {"id": "3f9a", "name": "Alice", "created_at": "2026-01-01T00:00:00Z"}
assert_that(actual).matches_structure({
"id": match.matches_regex(r"[0-9a-f]+"),
"name": "Alice",
"created_at": match.is_non_empty_string(),
})
is_equal_to(ignore=...) does the same for object graphs (dataclasses, attrs, Pydantic models), by key,
nested path, regex, or type - see Selective comparison.
Choose a snapshot style¶
Store the expected value in a file with snapshot(), or inline in the test source with
matches_inline(). Record both the same way - run pytest --assertpy2-snapshot-update - and both honor
the same selective knobs and CI mode:
def test_report():
# file snapshot -> __snapshots/snap-test_report.json
assert_that(build_report()).snapshot()
# inline snapshot -> the literal is written into this line
assert_that(build_summary()).matches_inline()
Use inline for small, literal-able values you want to read next to the test. Use file snapshots for
large payloads or values that need a custom serializer (datetime, Decimal, a domain object). Inline
snapshots need the [inline] extra (pip install assertpy2[inline]). See
Snapshot testing.
Assert on a filtered, mapped collection¶
Chain the collection pipeline to narrow a list before asserting, instead of writing a loop:
orders = [
{"id": 1, "total": 100, "status": "paid"},
{"id": 2, "total": 50, "status": "pending"},
{"id": 3, "total": 200, "status": "paid"},
]
assert_that(orders).filtered_on(lambda order: order["status"] == "paid").mapped(
lambda order: order["total"]
).contains_only(100, 200)
first(), last(), single(), and element(i) pivot to one element. flat_mapped() flattens. See
Collection pipeline.
Collect every failure in one run¶
A soft-assertion block reports all failures at once instead of stopping at the first, with a file:line
for each - ideal for validating many fields of one object:
from assertpy2 import soft_assertions
user = {"name": "Alice", "age": 30, "email": "alice@example.com"}
with soft_assertions():
assert_that(user["name"]).is_not_empty()
assert_that(user["age"]).is_between(0, 120)
assert_that(user["email"]).contains("@")
See Soft assertions.
Assert on what was logged¶
caplog captures the records, and extracting reads the fields worth asserting on. A failure prints
every record it captured, so the one you expected is compared against the whole set rather than against
nothing:
import logging
logger = logging.getLogger("billing")
def charge(order: dict[str, int]) -> None:
logger.error("timeout talking to billing for order %s", order["id"])
def test_the_timeout_is_logged(caplog):
with caplog.at_level(logging.INFO):
charge({"id": 7})
assert_that(caplog.records).extracting("levelname", "message").contains(
("ERROR", match.contains_string("timeout"))
)
assert_that(caplog.records).extracting("levelname").does_not_contain("WARNING")
A matcher works inside the extracted tuple, which is what keeps this readable when the message carries an id or a duration you do not want to spell out.
Test exceptions and their cause chain¶
Capture an exception with raises().when_called_with(), then keep asserting on it - the message, the
cause chain, or a member of an ExceptionGroup:
def load(value):
try:
int(value)
except ValueError as error:
raise RuntimeError("load failed") from error
assert_that(load).raises(RuntimeError).when_called_with("x").caused_by(ValueError)
raised() pivots to the exception object, has_root_cause() walks the whole chain, and
contains_error() matches inside an ExceptionGroup, where errors() hands back the failures
themselves - see Expected exceptions.
Wait for eventual consistency¶
When a value settles asynchronously (a queue drains, a cache warms), poll with eventually() (async) or
eventually_sync() (blocking) instead of sleep. A timeout reports a convergence trace:
See Polling assertions.
Keep the value, statically narrowed¶
An assertion hands the value back, narrowed for the type checker, so you can assert and use it in one
step without a cast or a bare assert:
value = assert_that("hello world").is_not_none().is_instance_of(str).value
assert_that(value.upper()).is_equal_to("HELLO WORLD")
is_not_none() strips None, is_instance_of() narrows to the class. The returned .value carries the
narrowed type. See Typed narrowing.
Add a project-specific assertion¶
Register a reusable matcher for a domain rule once, then use it everywhere via match.* or satisfies():
from assertpy2 import register_matcher
register_matcher(
"is_valid_sku", lambda value: bool(re.fullmatch(r"[A-Z]{3}-\d{4}", value))
)
assert_that("ABC-1234").satisfies(match.is_valid_sku())
For a whole family of chainable assertions on your own type, use add_extension - see
Custom assertions.
When full structural comparison is too much¶
For a very large object, comparing the whole thing is slow and the diff is noisy. Extract just the parts you care about and assert on those:
response = {
"id": "abc-123",
"items": [{"sku": "A"}, {"sku": "B"}],
"meta": {"total": 2, "page": 1},
}
assert_that(response["items"]).is_length(2)
assert_that(response).has_json_path("$.meta.total")
assert_that(response["meta"]["total"]).is_equal_to(2)
extracting() pulls a field or JSON path off every element of a collection. has_json_path() and
at_json_path() navigate into a nested payload, and need the JSON extra
(pip install assertpy2[json]). This keeps the failure focused on the field that broke.
Migrate an assertion from plain assert¶
Replace a cluster of bare asserts with one fluent chain - fewer statements, a structured diff on failure, and type-aware autocomplete:
items = ["viewer", "editor", "admin"]
# before: three statements, no diff, no autocomplete
assert isinstance(items, list)
assert len(items) == 3
assert "admin" in items
# after: one chain
assert_that(items).is_instance_of(list).is_length(3).contains("admin")
Coming from the original assertpy, the switch is one import: see the
Migration page.