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 assertpy 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 not the only copy. The raised AssertionFailure carries failures, one record per
collected failure: values, diff, group label and (file, line). See
What a soft block hands back.
An exception raised inside the block still wins, unchanged: same type, message and traceback, so
except TimeoutError around the block keeps working. What the block had collected travels with it as a
note:
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 note is attached too and readable as
exc.__notes__, but the traceback does not print notes there. - Current pytest's
match=searches notes as well, sopytest.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 assertpy 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():
Both take seconds as a real number, checked where given (eventually(), eventually_sync(), within(),
every()):
- A bool, text or a
DecimalraisesTypeError. A negative or NaN one raisesValueError. - An infinite timeout, or one past the float range, waits until the condition holds.
- The interval must be finite. One longer than the time left sleeps only to the deadline, so the poll fails on time after one last try.
The subject is a callable, by design. A value is refused: polling it would re-read nothing, and the
library cannot tell an immutable value 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)
Every call on the chain, a 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
awaitruns the chain: without it nothing is polled. A chain dropped without being awaited raises aRuntimeWarning, as a dropped coroutine does, and--assertpy-danglingreports it from the source. - Awaiting returns the ordinary builder over the settled value, so assertions after it are plain assertions, not a new wait.
By default only a failing assertion is retried, and any other exception from the probe propagates at once.
An AssertionError the probe raises itself is retried the same way, which suits a probe that asserts its
own preconditions. The timeout message names its type and 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.
The timeout bounds the retries, not a single call. A call in flight is never interrupted, so a probe that takes 30 seconds makes a 5-second poll take 30. Bound the call itself, with the timeout of the client it uses.
Synchronous polling¶
eventually_sync() is the same polling assertion without asyncio. Its assertions block the thread (via
time.sleep) instead of returning coroutines, so it works in plain 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 probe must be a sync callable: one returning an awaitable raises TypeError. Poll async probes with
eventually() and await.
asyncio.run() around eventually() works only where nothing else owns a loop in that thread.
Playwright's sync API drives its own loop, and the call fails with RuntimeError: asyncio.run() cannot be
called from a running event loop. That 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 |
Counts in that line cover the whole run, not only the samples kept: a long poll drops middle samples, and
a value that moved on all of a thousand polls reads as a thousand. A line about the shape of what was
kept says so: value cycles between 2 states in the 25 polls kept.
The raised AssertionFailure carries the timeline as .trace, a PollTrace
of per-poll samples, identical consecutive polls collapsed. The pytest report gets a Polling Trace
section: the one-line summary (polls, elapsed time, how the value moved), then every distinct poll with
its offset, repeat count and error or failure:
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 gets the same timeline as a
Polling TraceJSON attachment, with diffs between consecutive distinct samples. - Samples are point-in-time snapshots, correct even when the probe mutates and returns the same object. Long polls keep the first 5 and last 20 samples.
- In soft mode the message keeps the trend line, and a collected timeout keeps its trace as
failures[i].traceon the aggregate. Warn mode logs the message and keeps nothing. trace=Falseturns the recorder off per assertion, oneventually()andeventually_sync().
What the recorder costs:
- A poll that fails with a value reads it twice: once for the sample, once, whole, to tell whether it moved, so a change past the hundredth item is still seen.
- A passing poll, a poll whose probe raised, and a chain settling on its first probe walk nothing.
- So the cost is that of failing polls, growing with the payload: a few microseconds over a scalar, 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 |
Turn it off for the rare case of a near-zero interval over a heavy value. The timeout failure then reports only the last failure.
Steps multiply by polls, since each poll replays the chain: a scalar probe settling on its eleventh call took 117 µs with one step and 150 µs with five. A failing step ends the poll before the steps behind it, so put the cheap check that catches the common failure 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:
assertpy 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 assertpy_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]
assertpy_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 asks the page 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.
Supported: dict, list, set, objects, numbers, None, complex, datetime/date/time,
Decimal and bytes. Commit 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, a snapshot is keyed by the line of the call. All cases of a parametrised test share that
line, so 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
A shared key fails loudly when the values differ. When they agree it passes silently, asserting one case
out of many, so a SnapshotKeyReusedWarning names the key and the 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 snapshotting twice in one test asserts both values silently. If the second call fails, the failure says why the two values were compared:
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 --assertpy-snapshot-update and every failing snapshot comparison overwrites the
stored value instead of failing.
- Each overwrite emits a
SnapshotUpdatedWarning, so the run reports which snapshots changed. - Matching snapshots are left untouched.
- The comparison options (
ignore,tolerance, ...) decide whether a snapshot is stale.
For runners other than pytest, set ASSERTPY_SNAPSHOT_UPDATE=1. Deleting the snapshot files and
re-running works too, each fresh capture emitting a SnapshotCreatedWarning.
CI mode¶
A first run creates a missing snapshot and passes. In CI that means a golden that was never committed is created in the throwaway workspace, the test passes, and drift detection is silently off.
Enable CI mode to make a missing snapshot a hard failure instead:
- It turns on by itself when a
CIenvironment variable is set, or withASSERTPY_SNAPSHOT_CI=1. --assertpy-snapshot-no-ciorASSERTPY_SNAPSHOT_CI=0turns the autodetection off.- Local runs are unaffected.
Obsolete snapshots¶
When a test is deleted its stored snapshot lingers on disk. At the end of a run assertpy reports snapshots it did not touch:
assertpy 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 conservative:
- An obsolete sub-snapshot (a line-number key in a file whose module still ran) is pruned only under
update mode on a full run, so a run narrowed by
-k,-m,--lfor--ffnever deletes a snapshot whose test was merely deselected. - A whole obsolete file is only reported, never deleted.
- Under
pytest-xdistthe workers' touched snapshots are merged on the controller first, so a snapshot used on another worker is never taken for an orphan.
Inline snapshots¶
An inline snapshot keeps the expected value in the test source. Call matches_inline() empty, record
once with --assertpy-snapshot-update, and the literal is written into the call:
# before recording
assert_that(client.get("/orders/1").json()).matches_inline()
# after `pytest --assertpy-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 assertpy[inline]). The comparison does not.
Below Python 3.11 a call written inside an assert statement, which pytest rewrites, cannot be found
to record, and one given its value through ** may not be found either, depending on the platform.
Write matches_inline() on a line of its own there, with its literal in the call.
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 go to the controller and are applied once. A literal is written only if
every worker that ran the call accepts it. A worker that saw another value, or whose comparison rejects it,
stops the write, and the run says to record that call with -n 0.
One call holds one literal, so every case reaching it must accept it: the cases of a parametrize, the
turns of a loop.
- A case accepts a literal its own comparison passes, so cases differing only in an ignored field share one.
- If no literal satisfies them all, nothing is written and the run fails, naming the call and the values.
Give each case its own call, or use
snapshot(), which keys every case apart. - The case that asked for a literal must accept it too. Under a comparator or tolerance by which the value is not equal to itself, nothing is written and the run fails saying so.
When cases are asked:
- When it runs, a case is asked about every literal its call has been asked for so far.
- A case compared by plain data (a literal value, options without functions) is also asked at the end of
the run about literals later cases asked for, with its value and options as they were when it ran, so
a
dictthe test changes afterwards does not move it. - A case holding a comparator function, or a value that is not a literal, takes only literals asked for by the time it ran.
Literal details:
- A case is asked about the literal as Python builds it, so equal parts such as the two tuples in
((1, 2), (1, 2))are one object, as at test time. Python also shares them with equal constants elsewhere in the file, so a comparator that compares by identity may answer differently next run. - A recorded
set()orfrozenset({...})means Python's own types. A test that rebinds either name fails on the written call next run.
Where a literal is written:
- Only into files under pytest's rootdir, never under
site-packagesordist-packages: a call there belongs to an installed package. - Only into the text the call was found in. A file edited during the run is left alone and named.
-
In the file's own encoding and line ending.
-
A literal is written only into the call it was recorded from.
- The expected value must already be a literal:
matches_inline(EXPECTED)is refused under update mode, since a literal in its place would stop the test reading the constant. - Under
not_nothing is recorded: the literal would be the value the negation must differ from. - Only JSON-ish literals work: a
dict,list,tupleorsetof scalars. For adatetime,Decimal,UUIDor a custom object usesnapshot(), which shares the update flag, CI mode, selective comparison and structured diff.
Custom types¶
For a type beyond the built-in codec (set, complex, datetime/date/time, Decimal, bytes,
UUID, Enum), register a serializer so snapshot() round-trips it instead of raising:
from assertpy 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.
For the response's shape rather than its values, use matches_contract_snapshot(). It records paths and
type categories, never values, and 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
Contract snapshot <...>; rerun with --assertpy-snapshot-update to accept the new shape.
- No model is needed: the contract is inferred from the first response. Storage, update mode and CI mode
are those of
snapshot(). - With a pydantic model at hand, use
assert_conforms(..., exact=True)instead. - One observation cannot tell which fields are optional, so a field that is legitimately sometimes absent
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
first run's value. placeholders stores a shape token instead, and asserts the shape on every run:
from assertpy 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. - Each run asserts the field is present and satisfies the matcher (a
Matcheror a callable predicate), including the run that creates the snapshot and an update that rewrites it. A field failing its placeholder fails the run, and nothing is written. - Every other field is 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 takes the options of is_equal_to(): ignore, include, tolerance and comparators.
The file always stores the full value, and 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.