Comparison¶
The tables below compare assertpy2 side-by-side with the common alternatives - pytest's assert,
PyHamcrest, the original assertpy, and dirty-equals.
In short
assertpy2 unifies the fluent, matcher, and == styles in one typed package,
then adds thread- and async-safe soft assertions, async polling, structured failures, and rich
pytest diffs. It ships 39 composable matchers and over 100 assertion methods across 12 value types,
with no runtime dependencies on Python 3.11+.
All three styles, one import¶
assertpy2's fluent, matcher, and == styles are not mutually exclusive - a single import gives you all
three, mixable in the same test suite:
from assertpy2 import assert_that, match
# fluent chaining (the assertpy heritage)
assert_that(value).is_positive().is_less_than(100)
# matchers inside plain == (the dirty-equals style)
assert response == {"id": match.is_positive(), "name": match.is_non_empty_string()}
# composable matchers (the Hamcrest style)
assert_that(value).satisfies(match.greater_than(0) & match.less_than(100))
The approaches¶
- pytest
assertrewrites plainassertstatements to produce detailed introspection on failure. There is no API to learn and the failure output is excellent, but there are no reusable conditions and no fluent chaining. - PyHamcrest is a matcher framework:
assert_that(value, is_(greater_than(5))). assertpy2 provides the same composable-matcher model (&,|,~, custom matchers) inside a typed fluent API. - assertpy (the original) introduced the fluent
assert_that(x).is_...()chaining this project is built on. assertpy2 is its typed successor and substantially expands the assertion set. See Migrating from assertpy to switch. - dirty-equals (mis)uses
__eq__so you can writeassert response == {"id": IsPositive(), ...}. assertpy2 matchers work the same way inside==, so the same single dependency covers this style too.
In code¶
The same check - id is a positive integer and name is a non-empty string - in each library:
Only assertpy2 offers both the typed structural form and the == form from a single import.
When it fails¶
A nested response, after role comes back as "superadmin". What each library prints on failure:
Only assertpy2 prints the path (user.role) and the exact predicate that failed. dirty-equals and the
assertpy2 == form both hand rendering to pytest, which dumps the whole differing container for you to
scan. The fluent form trades the zero-import convenience of == for a path-level diff.
With a Pydantic model or attrs instance¶
When the value is a Pydantic model - or an attrs instance - matches_structure() accepts it directly,
with no .model_dump() / attrs.asdict() step: it is normalized to its fields and printed with a
path-level diff just like the one above:
dirty-equals cannot compare a model against a spec dict, because Pydantic's __eq__ only matches another
model. You dump it first (user.model_dump() == {"role": IsOneOf(...)}), and pytest again dumps the
whole differing container. assertpy2 keeps a path-level diff on either.
Style and typing¶
| pytest assert | PyHamcrest | assertpy | dirty-equals | assertpy2 | |
|---|---|---|---|---|---|
| Paradigm | rewritten assert |
matchers | fluent chain | == objects |
fluent + matchers + == |
| Mix styles in one suite | No | No | No | No | Yes |
Static typing (py.typed, overloads) |
n/a | No | No | Typed | Yes |
| Autocomplete filtered by value type | No | No | No | No | Yes |
| Typed narrowing (the assertion returns the value, narrowed) | No | No | No | No | Yes |
| Contract testing (validate a payload and narrow to the model) | No | No | No | No | Yes |
| Fluent chaining | No | No | Yes | No | Yes |
| Composable matchers | No | Yes | No | Yes | Yes |
Works inside plain == |
n/a | No | No | Yes | Yes |
Assertions and matchers¶
| pytest assert | PyHamcrest | assertpy | dirty-equals | assertpy2 | |
|---|---|---|---|---|---|
| Structural matching (nested) | manual | partial | No | Yes | Yes |
| Recursive comparison (tolerance / comparators / null-skip) | approx |
No | No | partial | Yes |
| Collection / ordering assertions | manual | Yes | Yes | Yes | Yes |
Negation of any assertion (.not_) |
manual | partial | No | partial | Yes |
| Collection pipeline (map / filter / flatten / navigate) | manual | No | No | No | Yes |
Dynamic attribute assertions (has_<name>()) |
No | No | Yes | No | Yes |
| Regex group extraction | manual | No | No | No | Yes |
| OpenAPI response-contract validation | No | No | No | No | Yes |
| JSON Path / JSON Schema | No | No | No | IsJson only |
Yes |
| File / date / bytes assertions | No | No | file, date | date | Yes (all) |
Exception cause chains / groups (caused_by, contains_error) |
manual | No | No | No | Yes |
| Data frame / array equality (pandas/polars/numpy) | manual | No | No | No | Yes |
| Custom assertions or matchers | functions | Yes | Yes | Yes | Yes (both) |
Reporting, safety and tooling¶
| pytest assert | PyHamcrest | assertpy | dirty-equals | assertpy2 | |
|---|---|---|---|---|---|
| Soft assertions | plugin | No | Yes | No | Yes |
| Soft assertions thread-safe and async-safe | n/a | n/a | No | n/a | Yes |
Grouped soft assertions (sa.group) |
No | No | No | No | Yes |
Async / sync polling (eventually() / eventually_sync()) |
No | No | No | No | Yes |
Structured failure data (.actual / .expected / .diff) |
No | No | No | No | Yes |
| Rich, recursive pytest diffs | built-in | No | No | No | Yes |
| Snapshot testing | plugin | No | Yes | No | Yes |
| Warn mode (non-failing assertions) | No | No | Yes | No | Yes |
| Allure / Behave integrations | No | No | No | No | Yes |
On snapshot testing: where assertpy2 does and does not lead
That row compares assertion libraries, not the dedicated snapshot tools - and it is worth being straight about the difference. If snapshots are central to your workflow, the specialists lead that niche: syrupy for external-file snapshots, and inline-snapshot for in-source snapshots.
assertpy2 gives you three snapshot styles bundled with the rest of your assertions - no extra tool, no extra dependency:
snapshot()- external-file, syrupy-family.matches_inline()- in-source literal. Records correctly underpytest-xdist, where inline-snapshot disables itself.matches_contract_snapshot()- value-tolerant structural snapshot (paths and type categories, not values), which neither syrupy nor inline-snapshot offers.
The first two are not category-leading engines, and do not try to be. Rule of thumb: reach for a specialist when snapshots are the point. Reach for assertpy2's when you want a snapshot inline with everything else, or structural (contract) regression rather than value-exact.
What only assertpy2 does here¶
Across the columns above, assertpy2 is the only option that:
- covers the fluent, matcher, and
==styles in a single import, mixable in one suite, so there is no juggling of libraries - is statically typed:
@overloadprotocols andpy.typedgive autocomplete filtered by the value's type and usage verified by a type checker before the test runs - returns the value it checked, statically narrowed (
.valueafteris_not_none()/is_instance_of()), and validates and narrows a whole payload against a Pydantic model withassert_conforms()- neither of which any other tool here does - has soft assertions that are both thread-safe and async-safe (independent state per thread and per
asyncio.Taskviacontextvars). The original assertpy's soft assertions are not thread-safe, and the other tools have no soft assertions at all - polls for eventual consistency with
eventually()(async) andeventually_sync()(blocking), for async operations and reactive systems - attaches structured failure data (
.actual/.expected/.diff) and renders rich, recursive diffs in pytest reports - adds exception cause-chain and group assertions, a collection pipeline, regex group extraction, dynamic
has_<name>()assertions, snapshot testing, JSON Path and Schema validation, file/date/bytes assertions, and Allure/Behave integrations.