A fully typed fluent assertion library for Python
A modern, batteries-included fork of assertpy
pip install assertpy2 # drop-in replacement for assertpy, just change the importfrom assertpy2 import assert_that
def test_user():
user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}
assert_that(user).contains_key("name", "age")
assert_that(user).contains_entry({"name": "Alice"})
assert_that(user["age"]).is_between(18, 120)
assert_that(user["roles"]).contains("viewer").does_not_contain("admin")The full documentation covers every assertion, matcher, and integration.
|
Failures that point at the difference A recursive diff names the exact path that differs, in color, instead of dumping both structures. |
Type-aware autocompleteassert_that() returns a protocol per value type, so your IDE offers the methods that fit.
|
|
Typed narrowing An assertion hands the value back statically narrowed, with no cast and no bare assert.
|
Composable matchers 41 matchers that combine with &, |, ~ and nest inside the expected structure itself.
|
|
Built for test suites Soft assertions, polling for eventual consistency, snapshots, and expected-exception chains. |
Integrations Allure, Behave, JSON Path and Schema, pandas, polars, numpy, and OpenAPI response contracts. |
assert states a condition well, and pytest reports it well.
What it cannot say is where two structures differ. It prints both and leaves the reading to you:
assert response == expected
E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
E Omitting 1 identical items, use -vv to show
E Differing items:
E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
E {'status': 'active'} != {'status': 'disabled'}
assertpy2 names the exact path, in color:
assert_that(response).is_equal_to(expected)It recurses through nested containers, and matcher predicates get the same treatment.
For dynamic fields like IDs, assert a subset with
matches_structure().
The chain is the other half: one statement carries the whole intent, and your IDE offers only the methods that fit the value.
assert_that(items).is_instance_of(list).is_length(3).contains("admin")Matchers are ordinary values that answer ==, the way unittest.mock.ANY does.
Nothing is patched, so a matcher can sit inside the expected structure itself, at any depth:
response = {"id": 7, "user": {"name": "Alice", "age": 30}, "tags": ["a", "b"]}
assert_that(response).is_equal_to(
{
"id": match.greater_than(0),
"user": {"name": "Alice", "age": match.between(18, 120)},
"tags": ["a", "b"],
}
)
# or keep the bare `assert`, and pytest's own rewriting reports it
assert response == {
"id": match.greater_than(0),
"user": match.ignore(),
"tags": ["a", "b"],
}The fluent form keeps the path-level diff, the bare form keeps pytest's.
There are 41 matchers, combining with &, | and ~.
assert_that() uses @overload to return type-specific Protocols.
Your IDE shows only methods relevant to the value you're testing, not all 100+:
assert_that("hello").→ string methods:starts_with,matches,is_alpha, ...assert_that(42).→ numeric methods:is_positive,is_between,is_close_to, ...assert_that(Path("/tmp")).→ path methods:exists,is_file,is_readable, ...assert_that(my_dict).→ dict methods:contains_key,contains_entry,has_json_path, ...assert_that(b"\x89PNG").→ bytes methods:starts_with_bytes,is_valid_utf8,decoded_as, ...
9 type-specific Protocols instead of one Any.
Works in PyCharm, VS Code, and any LSP-compatible editor.
An assertion hands the value back, statically narrowed.
is_not_none() strips None, is_instance_of() narrows to the class, and .value returns it:
order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
order.refund() # statically PaidOrder - verified by ty, mypy, and pyrightFor API tests,
assert_conforms()
validates a payload against a Pydantic model and narrows to it. exact=True catches contract drift:
data = assert_conforms(response.json(), OrderModel).value # data: OrderModelAn exception is the right default, and a dead end for anything that wants to read the result.
check() runs the next assertion for its verdict instead:
response = {"user": {"name": "Alice", "role": "superadmin"}, "status": "active"}
expected = {"user": {"name": "Alice", "role": "admin"}, "status": "active"}
outcome = assert_that(response).check().is_equal_to(expected)
if not outcome and outcome.diff:
print(outcome.diff.entries[0].path) # user.roleIt is truthy when the assertion held. When it did not, it carries .message, .actual, .expected
and a walkable .diff, and so does AssertionFailure.
So a reporter reads structure instead of parsing a string. That is how the Allure integration works, and it is open to anything else you build.
Fluent API
- Structural matching:
matches_structure()for declarative dict/API-response validation. - Recursive field assertions:
all_fields_satisfy()/has_no_none_fields()apply a predicate to every leaf of an object graph. - Vacuous-assertion guard:
--assertpy2-vacuouswarns when a universal assertion passes over an empty collection, having checked nothing. - Universal negation:
.not_inverts any assertion, no dedicatedis_not_*methods. - Collection pipeline:
filtered_on(),mapped(),flat_mapped(),first(),last(),element(),single(). - Positional & pairwise checks:
satisfies_exactly(),zip_satisfies(),contains_only_once(),has_same_size_as(), plus*_in_any_ordervariants.
Type safety
- Refinement predicates:
satisfies()takes aTypeIspredicate, so a domain check narrows the chain too. - Contract testing:
assert_conforms()validates a raw payload against a Pydantic model and narrows to it.exact=Truecatches contract drift,each=Truevalidates list endpoints.
Built-in types
- Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, bytes, files, exceptions.
- Bytes assertions:
is_valid_utf8(),starts_with_bytes(),is_hex_equal_to(),decoded_as()forbytes/bytearray. - Dynamic assertions:
has_<name>()for any attribute, property, or zero-argument method. - Dict comparison:
is_equal_to(ignore=..., include=...)for selective key/field matching by name, regex, or type. - Recursive comparison:
is_equal_to()withtolerance,comparators, orignore_nullfor nested structures. - Extracting: flatten collections on attributes with
filterandsortsupport.
Testing
- Soft assertions: thread-safe and async-safe via
contextvars, each failure reported with itsfile:line. Group withsa.group()orassert_all(). - Polling assertions:
eventually()(async) /eventually_sync()(blocking) retry for eventual consistency, with a convergence trace on timeout. - Expected exceptions:
raises().when_called_with(), walk the cause chain (caused_by(),has_root_cause()), matchExceptionGroup(contains_error()), or pivot to the object (raised()). - Structured errors:
AssertionFailurecarries.actual,.expectedand.diff, and the diff renders into the message, so it shows off pytest too. - Assertions as values:
check()runs the next assertion for its verdict instead of raising, handing back anAssertionOutcome. - Rich pytest diffs: recursive diffs across containers, dataclasses, attrs and Pydantic models, with intra-line carets for strings.
- Snapshot testing: an external JSON file, an inline value recorded into the test source, or a value-tolerant contract, all updated with
--assertpy2-snapshot-update. - OpenAPI response contracts:
conforms_to_openapi()checks a JSON body against an operation's response schema, reporting every violation with its JSON path.
Extensibility
- Custom matchers:
register_matcher()composes existing ones,BaseMatchercarries its own predicate. Both compose with&,|,~. - Custom assertions:
add_extension()adds a method to the builder. - Regex group extraction:
extracting_group()andmatches_with_groups()for regex captures.
- Allure (
pip install assertpy2[allure]): the pytest plugin auto-attaches structured diff and actual/expected data to Allure reports, in three configurable modes. - Behave (
pip install assertpy2[behave]): ready-made parameter types (PositiveInt,NonEmptyString, ...) for step definitions like{age:PositiveInt}. - JSON (
pip install assertpy2[json]): JSONPath navigation (at_json_path(),has_json_path()) and JSON Schema validation (matches_json_schema()). - Data frames (
pip install assertpy2[pandas]/[polars]/[numpy]): fluent equality for pandas/polars frames and numpy arrays, carrying each library's own diff.

