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.status_code).is_equal_to(200)
assert_that(resp.json()).matches_structure({
    "id": match.is_positive(),
    "status": match.is_in("paid", "pending", "shipped"),
    "customer": {"name": match.is_non_empty_string()},
})

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.

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 - 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. 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")

More before/after pairs, including unittest and the original assertpy, are on the Migration page.