Test assertions for Python. A language-neutral standard defines what each assertion checks and what its failure reports, and this repository runs the standard's cases against the library.
pip install dokimi-assertThe distribution is dokimi-assert, and the package is dokimi_assert. It
needs Python 3.11 or later and has no runtime dependencies. A property that
draws a zone needs the package tzdata on Windows, because Windows has no
time-zone database.
- Getting started
- Two surfaces
- The assertions
- Equality
- Golden files
- Trees of files
- Properties
- Histories
- Machines
- Testing your own assertions
- Call records
- The standard
- Development
Installing the package registers a pytest plugin, so every test can take
the seat fixture as a parameter.
from dokimi_assert import check
def test_trim_removes_the_padding(seat):
got = " widget ".strip()
check.is_not_empty(seat, got, "the trimmed name is not empty")
check.equal(seat, got, "widget", "trim removes the padding")Each assertion takes the seat first and the message last. The message is the contract under test, and the failure starts with it:
AssertionError: trim removes the padding: want 'widget', got 'gadget'
The order differs in three places:
rejectsandprop.for_alltake the message before the body.- An option, such as
equate_empty(), comes after the message. - The golden comparisons and the benchmark ceilings take no message.
An assertion reports each failure to the seat that the test passes, instead of calling pytest or raising. What a failure does depends on the seat:
| Seat | A failure on check |
A failure on expect |
|---|---|---|
Collector, from the seat fixture |
Raises AssertionError |
Is collected, and raised when the test body ends |
Standard |
Raises | Raises |
Recorder, from the recorder fixture |
Is kept | Is kept |
- The plugin hands each test a
Collector. When the test body ends, the plugin runs the functions that the test registered withseat.cleanup, and raises what the collector kept. The failure then belongs to the test and not to its teardown. - Construct a
Standardoutside pytest, where nothing ends a test. - Use a
Recorderto read what an assertion reported, throughfailed,message,messages,failures,faultsandrecords. - The
prop.Caseof a property is a seat too, so every assertion works inside a property.
A fault is an input or an environment that a call refuses, such as a
pattern that does not compile. A seat with the method report_fault
receives the fault as the exception it is, and a Recorder keeps it. A
Collector and a Standard raise the exception at the call.
seat.fault(seat, error) ends the call of a helper of your own in the same
way:
from dokimi_assert.seat import fault
def restart_of(seat, service):
if service.restart is None:
fault(seat, ValueError(f"{service.name} cannot restart"))
return None
return service.restartBoth modules have the same 48 assertions under the same names. check
stops the test at the first failure. expect records the failure and lets
the test continue, so one run reports every failure.
from dokimi_assert import check, expect
def test_reply(seat):
status, body, items, headers = 200, "[1,2]", [1, 2], {"date": "Wed, 22 Oct 2026"}
check.equal(seat, status, 200, "the request succeeds")
expect.has_prefix(seat, body, "{", "the body is JSON")
expect.length(seat, items, 3, "every item comes back")
expect.contains(seat, headers, "etag", "the reply is cacheable")The three expect calls fail, and the seat raises them together when the
test body ends:
AssertionError: 3 failures:
1. the body is JSON: got '[1,2]', prefix '{'
2. every item comes back: want 3, got 2
3. the reply is cacheable: haystack {'date': 'Wed, 22 Oct 2026'}, needle 'etag'
Use check when nothing after it makes sense, and expect when each line
states an independent property.
check and expect each have 48 assertions. The golden module has 4
comparisons, and a bench.Contract has 3 ceilings. The modules files,
prop, history and stateful have their own sections, and each section
ends with the reference of its module.
Each assertion takes the seat first and the message last.
check and expect have the same names and the same
signatures. Only what happens on a failure differs.
Equality. The comparison is structural and strict about types.
check.equal(seat: Seat, got: Any, want: Any, msg: str, *options: Option)
check.not_equal(seat: Seat, got: Any, want: Any, msg: str, *options: Option)Truth and absence. Python has one null value, None.
check.is_true(seat: Seat, condition: object, msg: str)
check.is_false(seat: Seat, condition: object, msg: str)
check.is_none(seat: Seat, got: Any, msg: str)
check.is_not_none(seat: Seat, got: Any, msg: str)Size. Each takes anything with a length. None has no length and fails all three.
check.length(seat: Seat, got: Any, want: int, msg: str)
check.is_empty(seat: Seat, got: Any, msg: str)
check.is_not_empty(seat: Seat, got: Any, msg: str)Containment. A str contains a substring, a mapping contains a key, and a sequence or a set contains an element by the rules of equal. is_permutation compares two sequences in any order.
check.contains(seat: Seat, haystack: Any, needle: Any, msg: str, *options: Option)
check.not_contains(seat: Seat, haystack: Any, needle: Any, msg: str, *options: Option)
check.contains_in_order(seat: Seat, got: Any, needles: Sequence[str], msg: str)
check.is_permutation(seat: Seat, got: Any, want: Any, msg: str, *options: Option)Text. Each takes a str and fails on any other value.
check.has_prefix(seat: Seat, got: Any, prefix: str, msg: str)
check.has_suffix(seat: Seat, got: Any, suffix: str, msg: str)
check.matches(seat: Seat, got: Any, pattern: str, msg: str)Numbers. Exact equality is the wrong check for a float.
check.close_to(seat: Seat, got: Any, want: float, tolerance: float, msg: str)
check.in_range(seat: Seat, got: Any, low: float, high: float, msg: str)Ordering. pairwise covers sorted, unique and strictly increasing, because each of those is a relation between neighbours.
check.pairwise(seat: Seat, items: Sequence[Any], predicate: Callable[[Any, Any], object], msg: str)Errors. These suit code that returns an error instead of raising it.
check.no_error(seat: Seat, error: BaseException | None, msg: str)
check.has_error(seat: Seat, error: BaseException | None, msg: str)
check.error_is(seat: Seat, error: BaseException | None, target: object, msg: str)
check.error_is_not(seat: Seat, error: BaseException | None, target: object, msg: str)
check.error_as(seat: Seat, error: BaseException | None, want: type[_E], msg: str) -> _E | NoneRaising. raises returns the exception of fn, so a test can assert on its class or its message.
check.raises(seat: Seat, fn: Callable[[], Any], msg: str) -> Exception | None
check.does_not_raise(seat: Seat, fn: Callable[[], Any], msg: str)Cancellation. Python cancels work through asyncio. honours_cancellation and honours_deadline take a coroutine function and run the event loop themselves, so the test is a plain def. completes_within calls fn on a daemon thread and fails at the deadline without waiting for it.
check.honours_cancellation(seat: Seat, fn: Callable[[], Awaitable[Any]], msg: str)
check.honours_deadline(seat: Seat, fn: Callable[[], Awaitable[Any]], msg: str)
check.completes_within(seat: Seat, within: float, fn: Callable[[], Any], msg: str)
check.none_handle_safe(seat: Seat, fn: Callable[[Any], Any], msg: str)Retrying. These suit a condition that something outside the test makes true. On the platform clock they spend real time. On a Controlled clock they advance the clock between attempts.
check.eventually(seat: Seat, timeout: float, interval: float, body: Callable[[Any], object], msg: str)
check.eventually_true(seat: Seat, timeout: float, predicate: Callable[[], object], msg: str)Concurrency. Call what it returns where the scope ends. A thread or an asyncio task that the scope started and that still runs then is a leak.
check.no_task_leaks(seat: Seat, msg: str) -> Callable[[], None]Purity. observe must return a copy, because a reading that shares state with the subject compares one object with itself.
check.is_pure(seat: Seat, observe: Callable[[], Any], fn: Callable[[], Any], msg: str, *options: Option)
check.is_not_pure(seat: Seat, observe: Callable[[], Any], fn: Callable[[], Any], msg: str, *options: Option)Relations. Each calls the subject with the values that the test passes. A relation that compares two results compares them by the rules of equal.
check.is_idempotent(seat: Seat, call: Callable[[Any], Any], value: Any, observe: Callable[[], Any], msg: str, *options: Option)
check.accumulates(seat: Seat, call: Callable[[Any], Any], value: Any, observe: Callable[[], Any], msg: str)
check.is_deterministic(seat: Seat, call: Callable[[Any], Any], value: Any, msg: str, *options: Option)
check.is_commutative(seat: Seat, combine: Callable[[Any, Any], Any], a: Any, b: Any, msg: str, *options: Option)
check.is_associative(seat: Seat, combine: Callable[[Any, Any], Any], a: Any, b: Any, c: Any, msg: str, *options: Option)
check.round_trip(seat: Seat, forward: Callable[[Any], Any], inverse: Callable[[Any], Any], value: Any, msg: str, *options: Option)
check.has_stable_order(seat: Seat, iterate: Callable[[], Any], msg: str, *options: Option)
check.no_duplicates(seat: Seat, iterate: Callable[[], Any], msg: str, *options: Option)
check.is_monotonic(seat: Seat, observe: Callable[[], Any], advance: Callable[[], Any], steps: int, msg: str)
check.is_total(seat: Seat, call: Callable[[Any], Any], domain: Iterable[Any], msg: str)
check.fails_after_close(seat: Seat, close: Callable[[], Any], call: Callable[[], Any], sentinel: object, msg: str)
check.is_poisoned(seat: Seat, induce: Callable[[], Any], observe: Callable[[], Any], msg: str)Testing an assertion. rejects takes the message before its body. It returns the failure records of the body, and fails when the body passes.
check.rejects(seat: Seat, msg: str, body: Callable[[Any], object]) -> list[Failure]Golden files. Each comparison reads recorded output and takes no message. A failure stops the test.
golden.match(seat: Seat, name: str, got: str, update: bool, *scrubbers: Scrubber)
golden.match_at(seat: Seat, path: str | PathLike[str], got: str, update: bool, *scrubbers: Scrubber)
golden.match_json_field(seat: Seat, path: str | PathLike[str], field: str, got: str, update: bool, *scrubbers: Scrubber)
golden.match_tree(seat: Seat, name: str, dir: str | PathLike[str], update: bool, *scrubbers: Scrubber)
golden.should_update() -> bool
golden.scrub_timestamps() -> Scrubber
golden.scrub_hashes() -> Scrubber
golden.scrub_run_ids() -> Scrubber
golden.scrub_json_fields(*fields: str) -> Scrubber
golden.Scrubber = Callable[[str], str]
golden.GOLDEN_DIR = 'testdata/golden'
golden.UPDATE_ENV = 'DOKIMI_ASSERT_UPDATE_GOLDEN'Benchmark ceilings. The ceilings chain onto one contract, and
check reports a verdict for each of them. allocs_checked
returns whether a run checks allocation ceilings.
bench.Contract(seat: Seat)
Contract.warmup(iterations: int) -> Contract
Contract.max_latency(seconds: float) -> Contract
Contract.max_mean(seconds: float) -> Contract
Contract.max_bytes(count: int) -> Contract
Contract.loop(iterations: int) -> Iterator[int]
Contract.excluding(setup: Callable[[], _T]) -> _T
Contract.check()
bench.allocs_checked() -> tuple[bool, str]Seats. An assertion reports each failure to its seat. The seat fixture is a Collector, and the recorder fixture is a Recorder.
Seat.helper()
Seat.fail(message: str)
Seat.record(message: str)
seat.Collector()
Collector.cleanup(fn: Callable[[], object])
Collector.with_clock(clock: Clock) -> Collector
seat.Standard()
seat.Recorder()
Recorder.with_clock(clock: Clock) -> Recorder
Recorder.failed: bool
Recorder.message: str
Recorder.messages: list[str]
Recorder.failures: list[Failure]
Recorder.faults: list[BaseException]
Recorder.records: list[str]
Recorder.helper_calls: int
seat.clean_up(collector: Collector)
seat.fault(seat: Seat, error: BaseException)Options of a comparison. An option relaxes or narrows equality for the one call that it is passed to.
option.equate_empty() -> Option
option.equate_nans() -> Option
option.by_identity() -> Option
option.OptionFailure records. A failure is a record of the assertion, the contract and the detail. render returns the sentence of a record.
failure.Failure(assertion: str, contract: str, detail: Mapping[str, Any], where: Where | None)
Failure.want: Any
Failure.got: Any
Failure.case_failure: Failure
failure.Where(file: str, line: int)
failure.render(failure: Failure) -> str
failure.register_sentence(sentence: Callable[[Failure], str], *assertions: str)
failure.call_site() -> Where | None
failure.site_in(traceback: TracebackType | None) -> Where | NoneClocks. An assertion that waits, retries or measures reads the clock of its seat. A Controlled clock moves only when a test advances it.
Clock.now() -> float
Clock.sleep(duration: float)
clock.System()
clock.Controlled(start: float)
Controlled.advance(duration: float)Read the docstring of an assertion for its rules and the
meaning of each argument, with help(check.close_to) or in
your editor.
import asyncio
import json
import operator
from dokimi_assert import check
def test_shapes(seat):
err = check.raises(seat, lambda: json.loads("{"), "a truncated body is refused")
check.contains(seat, str(err), "line 1", "the error contains the line")
check.pairwise(seat, [1, 2, 2, 5], operator.le, "the log is ordered")
check.close_to(seat, 1.02, 1.0, 0.05, "the retry waited about a second")
check.matches(
seat, "req_0123456789abcdef", r"^req_[0-9a-f]{16}$", "the id is well formed"
)
async def serve() -> None:
await asyncio.sleep(60)
def test_serve_stops_when_told(seat):
check.honours_cancellation(seat, serve, "the server stops when told")The subject of honours_cancellation is a coroutine function, and the test
is a plain def. The assertion runs the event loop itself, so the test
does not need an async plugin.
equal compares two values only when their types are the same:
| Expression | Python's == |
equal |
|---|---|---|
0 == False |
True |
Not equal |
1 == 1.0 |
True |
Not equal |
bool subclasses int, and the numeric types compare across each other,
so == equates both pairs. Under the definition, values of different
types are never equal.
An absent collection does not equal an empty one, and NaN does not equal itself. Where that difference does not matter, relax the comparison for one call:
from dokimi_assert import check
from dokimi_assert.option import equate_empty
def test_no_items_came_back(seat):
items = None
check.equal(seat, items, [], "no items came back", equate_empty())equate_empty() makes an absent collection equal an empty one, and
equate_nans() makes a NaN equal another NaN. by_identity() compares
references by identity, so two equal lists that are distinct objects
differ. An option applies to the call that it is passed to, and to no other
call.
from dokimi_assert import golden
def test_the_report_is_unchanged(seat):
report = "generated at 2026-10-22T09:30:00Z\ntotal: 3\n"
golden.match(
seat, "report.txt", report, golden.should_update(), golden.scrub_timestamps()
)The file testdata/golden/report.txt, below the directory where pytest
runs, contains the recorded output. DOKIMI_ASSERT_UPDATE_GOLDEN=1 pytest
writes it. The scrubbers apply to both sides, so the file contains
generated at SCRUBBED_TIMESTAMP. Read the diff before an update, because
an update accepts the current output of the code.
golden.match_tree compares a directory with a golden tree under
testdata/golden. It compares the owner's execute bit of each file, the
one bit that git records. An update makes the golden tree equal the output,
and it removes each entry that the output lacks.
import os
from dokimi_assert import files
def test_a_rename_moves_the_draft(seat):
root = files.workspace(seat, {"draft.txt": files.text("hello\n")})
os.rename(os.path.join(root, "draft.txt"), os.path.join(root, "final.txt"))
files.equal(
seat,
root,
{"final.txt": files.text("hello\n")},
"the draft becomes the final file",
)
files.absent(seat, os.path.join(root, "draft.txt"), "no draft is left")files.text,bytes,executable,directoryandlinkbuild the entries of a tree, a dict from a path to an entry.Entry.with_modesets the permission bits where they are part of the contract.workspacewrites a tree into a new directory and returns its path. The test removes the directory when it ends, so the seat needscleanup, as theseatfixture and aprop.Casehave it.writewrites a tree over a directory that exists, andreadreturns the content of a file.equal,containsandunchangedcompare a directory with a tree.absent,is_file,is_dir,links_to,has_contentandhas_modecheck the entry at one path.- Every assertion of
filesstops the test at a failure. - On a file system that does not record permission bits, such as a file
system of Windows, a comparison does not compare modes, and
has_modeends the call with a fault.
Trees. A tree maps each path to an entry. A path is relative to the root of the tree, and slashes separate its names.
files.Tree = Mapping[str, Entry]
files.text(content: str) -> Entry
files.bytes(content: builtins.bytes | bytearray) -> Entry
files.executable(content: str) -> Entry
files.directory() -> Entry
files.link(target: str) -> Entry
Entry.with_mode(mode: int) -> EntryWriting and reading. workspace writes a tree into a new directory that the test removes when it ends.
files.workspace(seat: Seat, tree: Tree) -> str
files.write(seat: Seat, dir: str | PathLike[str], tree: Tree)
files.read(seat: Seat, path: str | PathLike[str]) -> strComparing a tree. Each compares the tree in a directory with a wanted tree.
files.equal(seat: Seat, dir: str | PathLike[str], want: Tree, msg: str)
files.contains(seat: Seat, dir: str | PathLike[str], want: Tree, msg: str)
files.unchanged(seat: Seat, dir: str | PathLike[str], fn: Callable[[], object], msg: str)Checking one path. Each reads the entry at the path itself and does not follow a link there.
files.absent(seat: Seat, path: str | PathLike[str], msg: str)
files.is_file(seat: Seat, path: str | PathLike[str], msg: str)
files.is_dir(seat: Seat, path: str | PathLike[str], msg: str)
files.links_to(seat: Seat, path: str | PathLike[str], target: str, msg: str)
files.has_content(seat: Seat, path: str | PathLike[str], want: str | builtins.bytes | bytearray, msg: str)
files.has_mode(seat: Seat, path: str | PathLike[str], want: int, msg: str)import operator
from dokimi_assert import check, prop
def sorted_is_ordered(c: prop.Case) -> None:
xs = c.draw(prop.list(prop.integer(-100, 100)), "xs")
check.pairwise(c, sorted(xs), operator.le, "sorted orders the list")
def test_sorted(seat):
prop.for_all(seat, "a sorted list is ordered", sorted_is_ordered)prop.for_all runs the body against generated cases. The body draws its
inputs from its prop.Case, and it asserts on the case in the same way as
a test asserts on its seat. A failing case shrinks to the smallest case
that fails the same way:
from dokimi_assert import check, prop
def reverse_starts_with_first(c: prop.Case) -> None:
xs = c.draw(prop.list(prop.integer(0, 9)), "xs")
check.equal(
c, list(reversed(xs))[:1], xs[:1], "the reverse starts with the first element"
)
def test_reverse(seat):
prop.for_all(
seat, "a reversed list starts with its first element", reverse_starts_with_first
)AssertionError: a reversed list starts with its first element: counterexample after 2 valid and 0 rejected cases, seed 17964245989312729279
xs: [0, 1]
failure of equal at test_reverse.py:6: the reverse starts with the first element: want [0], got [1]
replay: prop.replay("prop1:AAEAAAABAAEAAA") or DOKIMI_ASSERT_PROP_REPLAY=prop1:AAEAAAABAAEAAA
- A failing run writes its minimal case to the store of the test under
testdata/prop, and later runs replay it first. - A property form, such as
prop.equalorprop.round_trip, runs one assertion on every generated input. A form reads the type of its input from the annotation of its function, andprop.ofderives the generator of a type.prop.of_shapereturns the generator of a shape file. prop.fuzzreplays the stored cases of a property and returns the target of a fuzzer.DOKIMI_ASSERT_PROP_SEED,DOKIMI_ASSERT_PROP_PROFILE,DOKIMI_ASSERT_PROP_BUDGETandDOKIMI_ASSERT_PROP_REPLAYset the defaults of every property of a run. The profile isdefault,ciorcampaign. The optionprop.hermetic()makes a run ignore the four variables.
prop.around runs each run of the body inside a wrapper, so that every
case, shrink candidate and replay starts in an environment of its own, such
as a frozen clock:
from collections.abc import Callable
import time_machine
from dokimi_assert import prop
def frozen(run: Callable[[], None]) -> None:
with time_machine.travel(0, tick=False):
run()
def test_backoff(seat):
prop.for_all(
seat, "a retry waits for its backoff", waits_for_backoff, prop.around(frozen)
)A wrapper that does not call run, calls it a second time or raises ends
the call with a fault.
prop.using(t, generator) makes generator the generator of t in one
run of for_all, fuzz or a property form. A draw of prop.of(t) decodes
with it, and a draw of prop.of(u) generates each t inside u with it.
Inside the generator, prop.of(t) is the generator of t without the
option. The generator can then narrow the values of its own type:
def test_a_paid_order_ships(seat):
open_orders = prop.of(Status).filter(lambda s: s is not Status.CANCELLED)
prop.for_all(
seat, "a paid order ships", ships_when_paid, prop.using(Status, open_orders)
)prop.values(generator, seed, n) returns the values of the first random
cases of a seed outside a property. A benchmark then measures the same
inputs in every run. values also takes the options of prop.using, so
the benchmark and a run of the property draw from one domain:
open_orders = prop.of(Status).filter(lambda s: s is not Status.CANCELLED)
orders = prop.values(prop.of(Order), 42, 100, prop.using(Status, open_orders))Running a property. for_all runs the body against generated cases, and the body draws its inputs from its case.
prop.for_all(seat: Seat, contract: str, body: Callable[[Case], object], *options: Option)
prop.fuzz(seat: Seat, contract: str, body: Callable[[Case], object], *options: Option) -> Target
Case.draw(generator: Decoder[_T], label: str) -> _T
Case.assume(condition: bool)
Case.skip(reason: str) -> NoReturn
Case.classify(label: str)
Case.target(label: str, score: float)
Case.note(message: str)
Case.observe(fingerprint: int)
Case.cleanup(fn: Callable[[], object])
Case.rand() -> Random
Case.history() -> History
prop.values(generator: Generator[_T], seed: int, n: int, *options: Option) -> list[_T]Options of a run. A later option of one kind replaces an earlier one. Each require and each using adds to the earlier ones.
prop.Option
prop.cases(n: int) -> Option
prop.seed(s: int) -> Option
prop.replay(token: str) -> Option
prop.require(label: str, share: float) -> Option
prop.shrink(runs: int) -> Option
prop.shrink_time(seconds: float) -> Option
prop.max_choices(n: int) -> Option
prop.store(directory: str) -> Option
prop.explain(enabled: bool) -> Option
prop.workers(n: int) -> Option
prop.hermetic() -> Option
prop.draws(entries: str) -> Option
prop.around(wrapper: Wrapper) -> Option
prop.Wrapper = Callable[[Callable[[], None]], object]
prop.using(t: type[Any], generator: Generator[Any]) -> OptionGenerators. Each returns a Generator. map, filter and bind derive a new generator from one.
Generator.map(f: Callable[[_T_co], _U]) -> Generator[_U]
Generator.filter(keep: Callable[[_T_co], bool]) -> Generator[_T_co]
Generator.bind(f: Callable[[_T_co], Generator[_U]]) -> Generator[_U]
prop.boolean(p: Fraction | int) -> Generator[bool]
prop.integer(lo: int, hi: int) -> Generator[int]
prop.float(lo: builtins.float, hi: builtins.float, allow_nan: bool, width: Literal[32, 64]) -> Generator[builtins.float]
prop.duration(lo: int, hi: int) -> Generator[int]
prop.string(alphabet: str | None, min_size: int, max_size: int | None) -> Generator[str]
prop.string_matching(pattern: str) -> Generator[str]
prop.bytes(min_size: int, max_size: int | None) -> Generator[builtins.bytes]
prop.list(of: Generator[_T], min_size: int, max_size: int | None, unique: bool) -> Generator[builtins.list[_T]]
prop.dict(keys: Generator[_K], values: Generator[_V], min_size: int, max_size: int | None) -> Generator[builtins.dict[_K, _V]]
prop.optional(of: Generator[_T]) -> Generator[_T | None]
prop.one_of(*generators: Generator[_T]) -> Generator[_T]
prop.just(value: _T) -> Generator[_T]
prop.sampled_from(*values: _T) -> Generator[_T]
prop.permutation(*values: _T) -> Generator[builtins.list[_T]]
prop.recursive(base: Generator[_T], extend: Callable[[Generator[_T]], Generator[_T]], max_leaves: int) -> Generator[_T]
prop.composite(f: Callable[[Case], _T]) -> Generator[_T]Types and shapes. of derives the generator of a type from its shape. A registration replaces the generator or the shape of a type in the test process.
prop.of(t: type[_T]) -> Generator[_T]
prop.of_shape(text: str) -> Generator[Any]
prop.shape_of(t: type) -> str
prop.register(t: type[_T], generator: Generator[_T])
prop.register_values(t: type[_T], *values: _T)
prop.register_variants(t: type, *variants: type)
prop.Variant(name: str, payload: Any)
prop.WallTime(local: datetime, zone: ZoneInfo)Constraints. Each is an argument of typing.Annotated, as the constraints of annotated-types are.
prop.AllowInfinity()
prop.AllowNan()
prop.Alphabet(characters: str)
prop.Char()
prop.Pattern(regex: str)
prop.Scale(digits: int)
prop.Unit(name: Literal['s', 'ms', 'us', 'ns'])
prop.Version(number: Literal[4, 6])
prop.Width(bits: int, signed: bool)Forms. Each form runs its assertion on every generated input. A form takes the options of a run, the options of a comparison that its assertion takes, and example and examples.
prop.equal(seat: Seat, got: Callable[..., Any], want: Callable[..., Any], msg: str, *options: FormOption)
prop.not_equal(seat: Seat, got: Callable[..., Any], want: Callable[..., Any], msg: str, *options: FormOption)
prop.is_true(seat: Seat, condition: Callable[..., object], msg: str, *options: FormOption)
prop.is_false(seat: Seat, condition: Callable[..., object], msg: str, *options: FormOption)
prop.is_none(seat: Seat, got: Callable[..., Any], msg: str, *options: FormOption)
prop.is_not_none(seat: Seat, got: Callable[..., Any], msg: str, *options: FormOption)
prop.length(seat: Seat, got: Callable[..., Any], want: int, msg: str, *options: FormOption)
prop.is_empty(seat: Seat, got: Callable[..., Any], msg: str, *options: FormOption)
prop.is_not_empty(seat: Seat, got: Callable[..., Any], msg: str, *options: FormOption)
prop.contains(seat: Seat, got: Callable[..., Any], needle: Any, msg: str, *options: FormOption)
prop.not_contains(seat: Seat, got: Callable[..., Any], needle: Any, msg: str, *options: FormOption)
prop.contains_in_order(seat: Seat, got: Callable[..., Any], needles: Sequence[str], msg: str, *options: FormOption)
prop.is_permutation(seat: Seat, got: Callable[..., Any], want: Callable[..., Any], msg: str, *options: FormOption)
prop.has_prefix(seat: Seat, got: Callable[..., Any], prefix: str, msg: str, *options: FormOption)
prop.has_suffix(seat: Seat, got: Callable[..., Any], suffix: str, msg: str, *options: FormOption)
prop.matches(seat: Seat, got: Callable[..., Any], pattern: str, msg: str, *options: FormOption)
prop.close_to(seat: Seat, got: Callable[..., Any], want: float, tolerance: float, msg: str, *options: FormOption)
prop.in_range(seat: Seat, got: Callable[..., Any], low: float, high: float, msg: str, *options: FormOption)
prop.pairwise(seat: Seat, got: Callable[..., Sequence[Any]], predicate: Callable[[Any, Any], object], msg: str, *options: FormOption)
prop.no_error(seat: Seat, fn: Callable[..., BaseException | None], msg: str, *options: FormOption)
prop.has_error(seat: Seat, fn: Callable[..., BaseException | None], msg: str, *options: FormOption)
prop.error_is(seat: Seat, fn: Callable[..., BaseException | None], target: object, msg: str, *options: FormOption)
prop.error_is_not(seat: Seat, fn: Callable[..., BaseException | None], target: object, msg: str, *options: FormOption)
prop.error_as(seat: Seat, fn: Callable[..., BaseException | None], want: type[BaseException], msg: str, *options: FormOption)
prop.raises(seat: Seat, fn: Callable[..., Any], msg: str, *options: FormOption)
prop.does_not_raise(seat: Seat, fn: Callable[..., Any], msg: str, *options: FormOption)
prop.honours_cancellation(seat: Seat, fn: Callable[..., Awaitable[Any]], msg: str, *options: FormOption)
prop.honours_deadline(seat: Seat, fn: Callable[..., Awaitable[Any]], msg: str, *options: FormOption)
prop.none_handle_safe(seat: Seat, fn: Callable[..., Any], msg: str, *options: FormOption)
prop.is_pure(seat: Seat, observe: Callable[[], Any], fn: Callable[..., Any], msg: str, *options: FormOption)
prop.is_not_pure(seat: Seat, observe: Callable[[], Any], fn: Callable[..., Any], msg: str, *options: FormOption)
prop.is_idempotent(seat: Seat, call: Callable[..., Any], observe: Callable[[], Any], msg: str, *options: FormOption)
prop.accumulates(seat: Seat, call: Callable[..., Any], observe: Callable[[], Any], msg: str, *options: FormOption)
prop.is_deterministic(seat: Seat, call: Callable[..., Any], msg: str, *options: FormOption)
prop.is_commutative(seat: Seat, combine: Callable[[Any, Any], Any], msg: str, *options: FormOption)
prop.is_associative(seat: Seat, combine: Callable[[Any, Any], Any], msg: str, *options: FormOption)
prop.round_trip(seat: Seat, forward: Callable[..., Any], inverse: Callable[[Any], Any], msg: str, *options: FormOption)
prop.example(*values: Any) -> FormOnly
prop.examples(*values: Any) -> FormOnly
prop.FormOption = prop.Option | option.Option | FormOnlyfrom dokimi_assert import history
class Register:
def initial(self) -> int:
return 0
def next(self, state: int, op: history.Operation) -> list[int]:
if op.name == "write":
return [op.args[0]]
return [state] if op.returned(state) else []
def test_the_register_is_linearizable(seat):
calls = history.History()
calls.invoke(0, "write", [1], "x").ok(None)
calls.invoke(1, "read", [], "x").ok(0)
history.is_linearizable(seat, calls, Register(), "the register is linearizable")The read returns 0 after the write of 1 completed, so no order of the two calls fits the register. The failure contains the longest order of calls that the specification accepts, and the call that it rejects there:
AssertionError: the register is linearizable: violated in the partition of 'x'
steps 2, partitions 1, calls 2, concurrency 1
linearized: call 0 write(1) -> None
states: 1
rejected: call 2 read() -> 0
- A test records each call with
History.invokebefore the call starts. After the call returns, the test completes it withCall.ok,Call.failorCall.unknown. history.concurrentlystarts the clients together, andhistory.from_intervalsbuilds a history from a log of timed calls.- Calls that share a key form one partition, and the check searches each partition on its own.
history.spec_frombuilds a specification from a factory of the subject.history.is_serializableandhistory.has_snapshot_isolationcheck a history of list-append transactions.
Recording. A test records each call before the call starts, and completes it after the call returns.
history.History()
History.invoke(client: int, operation: str, args: Sequence[Any], *keys: Any) -> Call
History.events() -> list[Event]
Call.ok(output: Any)
Call.fail(error: Any)
Call.unknown(error: Any)
history.concurrently(clients: int, within: float, body: Callable[[int], object]) -> list[Outcome]
history.Outcome(client: int, finished: bool, output: Any)
history.from_intervals(entries: Sequence[Interval]) -> History
history.Interval(client: int, operation: str, args: Sequence[Any], keys: Sequence[Any], start: float, end: float | None, kind: Literal['ok', 'fail', 'unknown'] | None, output: Any, error: Any)
history.IntervalError(entry: int, reason: str)Events. History.events returns the events in recording order.
history.Event = history.Invocation | history.Completion
history.Invocation(index: int, call: int, client: int, process: int, operation: str, args: tuple[Any, ...], keys: tuple[Any, ...], kind: Literal['invoke'])
history.Completion(index: int, kind: Literal['ok', 'fail', 'unknown'], call: int, client: int, process: int, output: Any, error: Any)
history.Kind = Literal['invoke', 'ok', 'fail', 'unknown']Specifications. A spec returns the states that an operation can lead to from a state, and no state for an operation that it refuses.
Spec.initial() -> _S
Spec.next(state: _S, op: Operation) -> Sequence[_S]
Operation.name: str
Operation.args: tuple[Any, ...]
Operation.known: bool
Operation.output: Any
Operation.returned(value: Any) -> bool
history.spec_from(factory: Callable[[], Subject]) -> SubjectSpecChecks. is_linearizable checks a history against a spec. is_serializable and has_snapshot_isolation read a history of list-append transactions.
history.is_linearizable(seat: Seat, history: History, spec: Spec[_S], msg: str, *options: Option)
history.is_serializable(seat: Seat, history: History, msg: str)
history.has_snapshot_isolation(seat: Seat, history: History, msg: str)Options of a check. Each bounds the search of is_linearizable.
history.Option
history.budget(steps: int) -> Option
history.memo_limit(bits: int) -> Option
history.time_limit(seconds: float) -> Option
history.workers(n: int) -> OptionCalls and evidence. These are the types of the calls, the transactions and the evidence that the checks and their records use.
history.Span(call: int, completion: int | None, process: int, operation: CallOperation)
history.Transaction(call: int, completion: int | None, kind: Literal['ok', 'fail', 'unknown'] | None, process: int, args: tuple[Any, ...], output: Any, mops: tuple[Mop, ...])
history.Link(call: int, relations: tuple[Relation, ...])
history.Edge(source: int, target: int, relation: Relation, key: Any, value: Any, empty: bool, following: Any)
history.Observation(anomaly: Anomaly, calls: tuple[int, ...], key: Any, reads: tuple[list[Any], ...], value: Any, appender: int | None, following: Any, expected: tuple[Any, ...], whole: bool, future: Any, has_future: bool)
history.Evidence = history.Edge | history.Observationfrom collections import deque
from dataclasses import dataclass, field
from dokimi_assert import history, prop, stateful
class Queue:
def initial(self) -> tuple[int, ...]:
return ()
def next(
self, state: tuple[int, ...], op: history.Operation
) -> list[tuple[int, ...]]:
if op.name == "put":
return [(*state, op.args[0])]
head = state[0] if state else None
return [state[1:]] if op.returned(head) else []
@dataclass
class QueueMachine:
queue: deque[int] = field(default_factory=deque)
spec: Queue = field(default_factory=Queue)
@property
def actions(self) -> list[stateful.Action[tuple[int, ...]]]:
return [
stateful.Action("put", self.put, input=self.value),
stateful.Action("get", self.get),
]
def value(self, c: prop.Case, state: tuple[int, ...]) -> int:
return c.draw(prop.integer(0, 9), "v")
def put(self, c: prop.Case, client: int, v: int) -> None:
call = c.history().invoke(client, "put", [v])
self.queue.append(v)
call.ok(None)
def get(self, c: prop.Case, client: int, _: object) -> None:
call = c.history().invoke(client, "get", [])
call.ok(self.queue.popleft() if self.queue else None)
def test_the_queue_keeps_its_values_in_order(seat):
def body(c: prop.Case) -> None:
stateful.steps(c, QueueMachine(), stateful.clients(2))
prop.for_all(seat, "the queue keeps its values in order", body)- A machine has the sequential specification of a subject and its actions, with the simpler actions first.
stateful.stepstakes the steps of a case and makes each decision a choice of the case. A failing case then shrinks to the steps that the failure needs, and it replays from its token.- The clients of a concurrent section run on threads by default. A case
then runs up to
repeattimes, because threads do not replay. - With the option
stateful.tasks, the clients run as tasks of astateful.Scheduler. The releases of the scheduler are choices of the case, so a race replays and shrinks. A subject that runs as tasks awaits a future orasyncio.sleep(0)at each point where another task can run.
Machines. A machine has the spec of a subject and its actions. steps takes the steps of a machine in a case.
Machine.spec: Spec[S] | None
Machine.actions: Sequence[Action[S]]
stateful.Action(name: str, run: Callable[[Case, int, Any], object], weight: int, drain: bool, enabled: Callable[[S], bool] | None, input: Callable[[Case, S], Any] | None)
stateful.steps(case: Case, machine: Machine[_S], *options: Option)Options of steps. Each sets one bound of the steps of a case, or the way in which the clients of a concurrent section run.
stateful.Option
stateful.clients(n: int) -> Option
stateful.concurrent(n: int) -> Option
stateful.max_steps(n: int) -> Option
stateful.mean(n: int) -> Option
stateful.repeat(n: int) -> Option
stateful.swarm(on: bool) -> Option
stateful.tasks(scheduler: Scheduler) -> OptionSchedulers. With tasks, the clients run as tasks of a Scheduler. Its releases are choices of the case.
stateful.Scheduler(case: Case, strategy: Strategy)
Scheduler.spawn(task: Callable[[], Coroutine[Any, Any, object]])
Scheduler.run()
stateful.Strategy
stateful.uniform() -> Strategy
stateful.pct(depth: int) -> Strategyfrom dokimi_assert import check
def test_the_check_refuses_an_empty_name(seat):
failures = check.rejects(
seat,
"an empty name is refused",
lambda inner: check.is_not_empty(inner, "", "the name is not empty"),
)
check.equal(
seat, failures[0].assertion, "not-empty", "the check fails on the empty name"
)rejects returns the failure records of the body, so a test confirms that
the check fails for the reason that it was written to catch. Both surfaces
have it. The recorder fixture is the lower-level form. Drive an assertion
with it, then read failed, message and failures.
A check whose environment lacks a capability calls skip of its seat.
When it skips before it fails, rejects skips the test with the reason.
The check cannot run there, so it has no verdict on the subject. On a
Recorder, which cannot skip, rejects returns an empty list. A
unittest.SkipTest that the check raises propagates out of rejects
without a verdict.
With DOKIMI_ASSERT_RECORD=1, the seat of each test appends the call
record of every call to the user_properties of the test's item. Each
record is one property, named dokimi.assert.<seq>. pytest's JUnit XML
report writes each user property as a property element of the test's
testcase element.
A Recorder keeps the records of its calls as lines of JSON, and
Recorder.records returns them.
assert-spec defines the assertions. This library vendors the definition, and its tests check the library against it:
- The 195 cases of the corpus run on
checkandexpect. Python skips none of them. - The vectors run too: 57 for files, 80 for histories, 430 for properties and 14 for machines.
tests/test_definition.pychecks each of the definition's 110 assertions. An assertion is present, or the overlay declines it and it is absent.- The library implements 105 of the 110.
pairwise,completes_within,no_task_leaks,rejectsand the five error assertions have no corpus case.
- Python cancels work through asyncio.
honours_cancellationandhonours_deadlinetake a coroutine function, start it, and cancel it at its first await. - An assertion that receives a coroutine function runs it on an event loop
of its own. Inside a running event loop, such as an
async deftest, the call ends with a fault. The message of the fault contains the id of the assertion in the definition, such asnil-context-safe. no_task_leaksreturns a check that the test calls where the scope ends. A thread or an asyncio task that started in the scope and still runs then is a leak.completes_withincalls the subject on a daemon thread. A subject that has not returned at the deadline fails then, and it keeps running on its thread.- CPython does not keep a running count of allocations. The overlay
declines
max_allocs,max_allocs_with_setup,bench.Contract.max_allocs,prop.max_allocsandprop.max_allocs_with_setupfor that reason.bench.allocs_checked()returns False and the reason, so a test can report that its run does not check an allocation ceiling. Contract.max_bytesmeasures withtracemalloc, which slows the body. A contract that statesmax_bytesalso measures its latency on traced iterations, so set its ceilings from a traced run.
ergon manages the Makefile, the workflows and the configuration of the
tools. ergon init sync writes them again from its baseline and from the
files under .ergon/local.
make check # the gate of the repository
make test # the tests
make coverage # the tests under coverage, which fail below 100%
make fmt # format the sources
make api-docs # regenerate the API reference of this README
make help # list every targetmake check lints the Markdown files with rumdl and the workflows with
actionlint, and it checks the vendored definition with spec. For Python, it
runs ruff, a format check, mypy in its strictest mode, the tests, and
pip-audit over uv.lock. On Linux, it also runs the tests under coverage,
which fail below 100% of the statements and branches. CI runs the gate of
Python on Linux, macOS and Windows with Python 3.11 to 3.14.
A change to the package comes with a changeset, which ergon release add
writes. When CI passes on main, the workflow version.yml opens the
version pull request. Its merge publishes the package to PyPI through
Trusted Publishing. The nightly workflow proposes each new version of the
definition as a pull request.
The design is in RFC-0001, RFC-0002 and RFC-0004.
MIT. See LICENSE.