Skip to content

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:

await assert_that(queue_depth).eventually().within(10).is_zero()

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.