Testing¶
Soft assertions¶
By default a failure halts the test immediately. Soft assertions collect failures and raise them together at the end, so one run reports every problem.
Each collected failure carries its file:line, so you can jump straight to the assertion that failed:
from assertpy2 import assert_that, soft_assertions
with soft_assertions():
assert_that("foo").is_length(4)
assert_that("foo").is_empty()
assert_that("foo").is_equal_to("bar")
Collected failures raised at the end of the block
Note
Only assertion failures are collected. Errors like TypeError/ValueError and an explicit fail()
halt immediately. Use soft_fail() to collect a forced failure. Soft state is thread-safe and
async-safe (independent per thread and per asyncio.Task via contextvars).
The message is a rendering, not the only copy. The raised AssertionFailure carries failures, one
record per collected failure, with everything the text had flattened: values, diff, group label and the
(file, line) it was collected at. See
What a soft block hands back.
An exception from inside the block still wins, and it is left exactly as it was raised: same type, same
message, same traceback, so except TimeoutError around the block keeps working. What the block had
already collected travels with it as an exception note rather than being dropped:
TimeoutError: service did not answer
soft assertion failures:
1. Expected <1> to be equal to <2>, but was not. [test_orders.py:14]
On Python 3.10 the failures are attached the same way and stay reachable as exc.__notes__, but that
interpreter's traceback does not print notes, so there they are read rather than shown. add_note
arrived in 3.11.
One consequence for anyone matching on the message: current pytest searches notes as well, so
pytest.raises(..., match="^service did not answer$") around a soft block stops matching once a note is
attached. The message itself is unchanged.
Grouped soft assertions¶
Bind the collector with as sa and group failures by section with sa.group(label):
with soft_assertions() as sa:
with sa.group("Headers"):
assert_that(headers["Content-Type"]).is_equal_to("application/json")
with sa.group("Body"):
assert_that(body["status"]).is_equal_to("ok")
Grouped output
Note
Soft mode collects assertion failures only.
After a failed raises() / warns() + when_called_with() there is no captured value left to
assert on, so the rest of that one chain goes inert and is skipped silently. Independent
assertions that follow are collected as usual.
assert_all¶
A convenience wrapper for inline soft assertions, equivalent to wrapping the calls in
with soft_assertions()::
from assertpy2 import assert_all, assert_that
assert_all(
lambda: assert_that(x).is_positive(),
lambda: assert_that(y).is_not_none(),
lambda: assert_that(z).is_length(3),
)
Async assertions¶
eventually() creates a polling assertion that retries until the condition holds or a timeout is
reached, useful for eventual consistency and reactive systems:
By default it polls for 5 seconds every 0.5 seconds. Tune with within() and every():
The subject is a callable rather than a value, and that is the design rather than a limitation. A value
is refused outright, because polling one would re-read nothing: an immutable subject could only spin to
the timeout, and the library cannot tell one apart from a live object. The callable says what to read
again, so assert_that(lambda: element.text) asks the element each round.
Both sync and async callables work, and any assertion method is available after eventually():
await assert_that(async_get_status).eventually().is_equal_to("done")
await assert_that(get_name).eventually().starts_with("Al")
await assert_that(get_count).eventually().is_between(10, 20)
A chain keeps polling for as long as it is written. Every call on it, whether a navigation step like
described_as() or another assertion, is replayed against a fresh probe on each poll, and the whole
chain is awaited once at the end:
Negation is part of that chain too, which is how you wait for something to stop being true:
The await is what runs the chain, so leaving it out polls nothing. A chain that is dropped without
being awaited raises a RuntimeWarning, the same way a dropped coroutine does, and
--assertpy2-dangling reports it from the source at
collection.
Awaiting hands back the ordinary builder over the value that settled, so anything asserted on the result afterwards is a plain assertion against that value rather than a new wait.
By default only a failing assertion is retried. Any other exception raised by the probe propagates immediately.
The one exception is an AssertionError raised by the probe itself. It arrives where a failing
assertion arrives and is retried the same way, which is what a probe asserting its own preconditions
usually wants. The timeout message says so, naming the type and the count.
When "not ready yet" arrives as an exception, such as a refused connection while a service boots,
list those exception types in ignoring:
await assert_that(get_order).eventually(timeout=10, ignoring=ConnectionError).has_status(
"PAID"
)
# or configure fluently, like within()/every()
await assert_that(get_order).eventually().within(10).ignoring(
ConnectionError, TimeoutError
).has_status("PAID")
Note
Only AssertionError (plus any ignoring types) is retried. Other exceptions propagate
immediately. On timeout the last failure is chained for context. ignoring accepts only
Exception subclasses, so KeyboardInterrupt and friends can never be swallowed.
Polling itself is always strict, since retrying requires hard failures. Only the final timeout
failure honors the builder's mode: inside soft_assertions() it is collected instead of
raised, and under assert_warn() it is logged.
Synchronous polling¶
eventually_sync() is the same polling assertion without asyncio: assertion methods block the
calling thread (via time.sleep) instead of returning coroutines, so it drops straight into plain
non-async tests:
def test_status_converges():
assert_that(get_status).eventually_sync(timeout=10, interval=0.2).is_equal_to("ready")
# within()/every()/ignoring() work the same way
assert_that(get_order).eventually_sync().within(10).ignoring(ConnectionError).has_status(
"PAID"
)
Retry rules, chaining, negation, soft/warn behavior, and the polling trace are identical to
eventually():
assert_that(get_order).eventually_sync(timeout=10).is_instance_of(Order).has_status("PAID")
assert_that(get_status).eventually_sync(timeout=10).not_.is_equal_to("pending")
The one difference is that the probe must be a sync callable. One that returns an awaitable raises
TypeError, so poll async probes with eventually() and await.
Reaching for asyncio.run() to call eventually() from a non-async test works only where nothing
else owns a loop in that thread. Playwright's sync API is the common counter-example, since it drives
a loop of its own and the call fails with RuntimeError: asyncio.run() cannot be called from a running
event loop. That configuration is why eventually_sync() exists.
Polling trace¶
Every poll is recorded, so a timeout failure diagnoses itself instead of just reporting that time ran out. The message opens with a one-line trend that pins the failure mode:
| Trend line | What it means |
|---|---|
probe raised ConnectionError on all 12 polls |
the service never came up |
value unchanged across 12 polls |
it converged to the wrong value |
value changed 3 times; last change 0.4s before the deadline |
the timeout is too short |
value cycles between 2 states across 12 polls |
it keeps returning to earlier values, so waiting longer will not help |
value could not be compared across 12 polls |
the probe returns something that cannot be rendered, so movement is unknown |
Every count in that line is over the whole run, not over the samples the trace kept: a long poll drops
middle samples, and a value that moved on every one of a thousand polls has to read as a thousand rather
than as the two dozen still in hand. Where a line describes the shape of what was kept rather than
counting, it says so (value cycles between 2 states in the 25 polls kept).
The raised AssertionFailure carries the full timeline as .trace, a
PollTrace of per-poll samples with identical consecutive polls
collapsed.
In the pytest report a Polling Trace section leads with that one-line summary, covering total polls,
elapsed time and how the value moved. It then lists every distinct poll with its offset, repeat count
and the error or failure it produced:
t=+0.0s error x2: ConnectionError('boot')
t=+0.5s fail x2: Expected <'PENDING'> to be equal to <'PAID'>, but was not.
t=+1.5s fail x5: Expected <'SHIPPED'> to be equal to <'PAID'>, but was not.
Allure receives the same timeline as a typed Polling Trace JSON attachment, with diffs between
consecutive distinct samples.
Sample values are point-in-time snapshots, so they stay correct even when the probe mutates and returns the same object. They are capped like other attachments: long polls keep the first 5 and last 20 samples.
In soft/warn modes the message keeps the trend line, and so does the trace object: a timeout collected
by a soft block is reachable as failures[i].trace on the aggregate. Warn mode logs the message and
keeps nothing, having nothing to keep it on.
The recorder can be switched off per assertion with trace=False, on both eventually() and
eventually_sync().
On every poll that fails with a value it reads that value twice, once for the sample and once to tell whether the value moved. The second walk covers the whole value rather than the cut sample, so a change past the hundredth item of a container is still seen.
A poll that passes, or one whose probe raised, walks nothing, and so does a chain that settles on its first probe. What the recorder costs is therefore the cost of failing polls, and it grows with the payload: one failing poll is a few microseconds over a scalar and about 2 ms over two hundred records.
Ten of them, which is what a timeout usually reaches:
| Ten failing polls over | recorder on | recorder off |
|---|---|---|
| a scalar | 118 µs | 85 µs |
| a payload of two hundred records | 22 ms | 1.2 ms |
That second row is most of what a failing poll costs, which is why a tight interval over a large response is where turning the recorder off is worth it.
That is the rare case: a near-zero interval meeting a heavy probed value, where even point-in-time snapshots cost too much. The timeout failure then reports just the last failure.
Every step of a chain is replayed against a fresh probe on each poll, so steps multiply by polls. One scalar probe settling on its eleventh call measured 117 µs polled by a one-step chain and 150 µs by a five-step one. A step that fails ends that poll and the steps behind it do not run, so the cheap check that tells the common failure apart is worth putting first.
Polls that nearly timed out¶
A poll that converges on its fifth attempt is eventually() doing its job, so a retry on its own says
nothing.
Burning most of the budget before converging says a great deal. That run passed, and the next one on a slower machine will not. Under pytest those are collected across the run and listed at the end:
assertpy2 polls that nearly timed out:
tests/test_orders.py::test_status: converged on attempt 41 at 0.81s of 1.0s (81% of the budget)
Only polls past 70% of their timeout are named, so a healthy suite prints nothing. The report is advisory and never fails a run.
Move that bar or turn the report off with assertpy2_poll_report. A slow CI box converges late on
every poll, where the default turns a signal into a line of noise per run.
[tool.pytest.ini_options]
assertpy2_poll_report = "0.9" # only polls past 90% of their timeout, or "off" to say nothing
Setting it to off also stops the samples being collected, so nothing is measured at the poll site.
Polls are attributed to the test that made them, including those a fixture makes in setup or teardown.
Under pytest-xdist the workers ship their findings to the controller, which prints the combined list.
Waiting on a browser¶
A UI test is the polling case in its purest form: the page is asked something before it is ready, and
the answer settles a moment later. eventually_sync() takes any callable, so a driver read is a probe
like any other.
def test_the_banner_settles():
reads = iter(["", "", "Welcome, Alice"]) # a page that is not ready on the first two reads
assert_that(lambda: next(reads)).eventually_sync(timeout=2, interval=0.1).is_equal_to(
"Welcome, Alice"
)
With a real driver the probe is the read itself. Selenium has no assertions of its own, so this
replaces a hand-written WebDriverWait and keeps the failure diagnostics:
assert_that(lambda: driver.find_element(By.CSS_SELECTOR, "#banner").text).eventually_sync(
timeout=10, ignoring=(NoSuchElementException, StaleElementReferenceException)
).is_equal_to("Welcome, Alice")
Use Playwright's own expect() for locator assertions. It auto-waits, it retries, and it knows
things about the page that a generic poller cannot. What it does not cover is the data behind the
interface, and that is where an assertion library earns its place:
expect(page.get_by_role("heading")).to_have_text("Orders") # Playwright's job
order = assert_conforms(api.get(f"/orders/{order_id}").json(), OrderModel).value
assert_that(order.total).is_close_to(page_total, 0.01) # ours
The same split applies to Selenium once you leave the DOM: assert the page with the driver, assert the
payload, the database row or the extracted structure with assert_that().
Snapshot testing¶
Capture a data structure to disk as JSON and compare against it on every run.
On the first run the snapshot file is created, a SnapshotCreatedWarning is emitted, and the test
passes. On later runs the value is compared to the stored snapshot and the test fails on any mismatch.
Most Python structures are supported: dict, list, set, objects, numbers, None, complex,
datetime/date/time, Decimal, and bytes. Commit the snapshot artifacts (the __snapshots
folder) to source control.
Note
The capture warning makes a first run visible. Without it a wrong first capture would silently become the reference.
Under -W error (or filterwarnings = ["error"]) a new capture fails explicitly, which is
usually what you want in CI.
One id per case¶
Without an id, the snapshot is keyed by the line of the snapshot() call. Every case of a
parametrised test shares that line, so they share one key: the first case stores its value and the
rest are compared against it.
@pytest.mark.parametrize("name", ["alice", "bob", "carol"])
def test_user(name):
assert_that(load(name)).snapshot(id=f"user-{name}") # without the id, all three share one key
Sharing a key fails loudly when the values differ, naming two cases that look unrelated. It passes
silently when they agree, asserting one case out of however many, which is the worse half and the
reason a SnapshotKeyReusedWarning names the key and the source line as soon as a second test
reaches it. Raise it to an error if you would rather not rely on reading warnings:
What counts is two tests on one key, not two calls. A helper that snapshots twice inside one test asserts both values and stays silent. Should its second call then fail, the failure says so, since otherwise it reads as two unrelated values compared for no reason:
Expected <{'user': 'bob'}> to be equal to <{'user': 'alice'}>, but was not. This test reached
<__snapshots/snap-helper.json> more than once, so the value above was compared against what an
earlier call in the same test stored. Give each call its own snapshot(id=...).
Under pytest-xdist the cases of one parametrised test may land on different workers, where no single
process sees the second one, so that split is caught by a sweep at the end of the run instead.
Updating snapshots¶
Run pytest with --assertpy2-snapshot-update and every failing snapshot comparison overwrites the
stored value instead of failing.
Each overwrite emits a SnapshotUpdatedWarning, so the run reports exactly which snapshots changed.
Matching snapshots are left untouched, and the comparison options (ignore, tolerance, ...) are
honored when deciding whether a snapshot is stale:
For runners other than pytest, set the ASSERTPY2_SNAPSHOT_UPDATE=1 environment variable instead.
Deleting the snapshot files and re-running the suite still works too, and each fresh capture emits a
SnapshotCreatedWarning.
CI mode¶
A first run creates a missing snapshot and passes. That is handy locally, but in CI it means a snapshot test whose golden was never committed creates it in the ephemeral workspace, passes, and silently disables drift detection.
Enable CI mode to make a missing snapshot a hard failure instead:
It is also auto-enabled when a CI environment variable is set (the near-universal CI marker), or via
ASSERTPY2_SNAPSHOT_CI=1. Disable the autodetection with --assertpy2-snapshot-no-ci or
ASSERTPY2_SNAPSHOT_CI=0. Local runs are unaffected.
Obsolete snapshots¶
When a test is deleted its stored snapshot lingers on disk. At the end of a run assertpy2 reports snapshots it did not touch:
assertpy2 snapshots:
obsolete snapshot: __snapshots/snap-test_orders.json::42
obsolete snapshot file: __snapshots/snap-test_legacy.json
Each line carries a short hint on how to remove it. Reporting is always safe.
Removal is deliberately conservative. An obsolete sub-snapshot, meaning a line-number key in a file whose module still ran, is pruned only under update mode on a full run.
That way a run narrowed by -k, -m, --lf or --ff never deletes a snapshot that merely looks
unused because its test was deselected. A whole obsolete file is only ever reported, never deleted.
Under pytest-xdist the touched-snapshot sets from all workers are aggregated on the controller
first, so a snapshot exercised on another worker is never mistaken for an orphan.
Inline snapshots¶
An inline snapshot keeps the expected value in the test source instead of a separate file. Call
matches_inline() empty, record it once with --assertpy2-snapshot-update, and the literal is written
back into the call:
# before recording
assert_that(client.get("/orders/1").json()).matches_inline()
# after `pytest --assertpy2-snapshot-update`
assert_that(client.get("/orders/1").json()).matches_inline(
{"id": 1, "status": "paid"}
)
Later runs compare against the literal, and update mode overwrites it on drift, just as snapshot()
does. The same selective knobs apply, so volatile fields never make the snapshot brittle:
assert_that(order).matches_inline(
{"id": 0, "total": 42.0}, placeholders={"id": match.is_uuid()}, tolerance=0.01
)
A recorded literal holds the value captured on that run, so a placeholder field shows the captured id
rather than the 0 above. The placeholder governs the comparison, not what is written.
Recording needs the [inline] extra (pip install assertpy2[inline]). The comparison does not.
It is a plain equality check, so it runs under pytest-xdist and needs no source introspection or
assertion rewriting.
Under xdist the recorded edits are shipped to the controller and applied once, never written by workers in parallel.
Inline snapshots hold source literals, so only JSON-ish values work: a dict, list, tuple or
set of scalars.
For a datetime, Decimal, UUID or a custom object use snapshot() instead. The two are
complementary and share the same update flag, CI mode, selective comparison and structured diff.
Custom types¶
Beyond the built-in codec (set, complex, datetime/date/time, Decimal, bytes, UUID,
Enum), register a serializer for any other type so snapshot() stores and round-trips it instead of
raising:
from assertpy2 import register_snapshot_serializer
import pathlib
register_snapshot_serializer(pathlib.PurePath, str, pathlib.PurePath)
Matching is by isinstance, subclasses included. The registry is consulted before the built-ins, and
a later registration wins.
The decode half runs your own code on load, so it is a trusted, explicit opt-in. The automatic
instance decode never imports anything.
Contract snapshots¶
snapshot() compares exact values, so a response full of generated ids and timestamps needs ignore
or placeholders to stay stable.
When you care about the response's shape rather than its values, reach for
matches_contract_snapshot(). It records paths and type categories, never values, and on later runs
fails only on structural drift: a field added, removed, or retyped.
It is value-tolerant by construction, so dynamic ids, timestamps, and amounts (and 5 vs 5.0) change
freely. A real contract change fails with the drifted paths:
Expected <{...}> to match contract snapshot <...>, but the structure drifted:
+ promo_code
~ id number -> str
No hand-written model is needed. The contract is inferred from the first response, and it shares the
same storage, update mode and CI mode as snapshot().
The model-driven counterpart is
assert_conforms(..., exact=True). Reach
for that when you already have a pydantic model.
Because a contract is inferred from a single observation it cannot know which fields are optional, so a
legitimately sometimes-absent field reads as removed. Re-record with update mode when the contract
really changed.
Shape placeholders¶
comparators and ignore make the comparison tolerate volatile fields, but the golden still stores
the arbitrary value captured on the first run. placeholders instead records a self-documenting shape
token in the golden and asserts the field's shape on every run:
from assertpy2 import match
assert_that(response).snapshot(
id="order",
placeholders={
"id": match.is_uuid(),
"created_at": lambda ts: isinstance(ts, str),
},
)
The golden reads "id": {"__placeholder__": "a valid UUID string"} instead of a specific id, and each
run asserts the actual field is present and satisfies the matcher (a Matcher or a callable predicate)
rather than comparing it for equality.
Every other field is still compared exactly, so drift outside the placeholders is caught. Placeholders
apply to top-level keys of a dict-like value and combine with ignore.
Parameters¶
Snapshots are keyed by test filename plus line number by default. Override with id or path:
assert_that({"a": 1}).snapshot(id="my-custom-id")
assert_that({"a": 1}).snapshot(path="my-custom-folder")
Volatile fields and float noise¶
The comparison accepts the same selective options as is_equal_to(): ignore, include,
tolerance, and comparators - so timestamps, generated ids, or float jitter don't break snapshots.
The snapshot file always stores the full value. The options only shape the comparison:
assert_that(api_response).snapshot(
id="order", ignore=["created_at", ("user", "session_id")]
)
assert_that(metrics).snapshot(id="latency", tolerance=0.001)
assert_that(payload).snapshot(
id="user", comparators={"name": lambda a, e: a.lower() == e.lower()}
)
Known limitations¶
Beyond the JSON natives, these types survive a round-trip:
| Type | Stored as |
|---|---|
set |
a tagged list |
complex |
a tagged pair |
datetime / date / time |
ISO text, microseconds and timezone kept |
Decimal |
exact text, never a float |
bytes / bytearray |
base64, both compared as bytes |
uuid.UUID, Enum members |
their canonical text form |
any object with a __dict__ |
its attributes |
Anything else raises TypeError on capture. Teach the codec with
register_snapshot_serializer(), which is what a frozenset or a domain class needs.
Three more things worth knowing:
- A tuple comes back as a list, since JSON has no tuple. A snapshot of
(1, 2)compares as[1, 2]on the next run and fails, so convert tuples before snapshotting. - Snapshot ids are case-insensitive. Filenames are lower-cased, so two ids differing only by case land in one file.
- The write lock is not crash-safe. A process killed mid-write leaves a stale
.lockbeside the snapshot. Delete it if snapshot writes start timing out.