Test assertions, property-based tests, history checks and state machines for Rust. A language-neutral standard defines what each one checks and what its failure reports, and this repository runs the standard's cases against the library.
The crates are not published on crates.io. Cargo takes them from this repository:
[dev-dependencies]
dokimi-assert = { git = "https://github.com/dokimasia/assert-rust", features = ["derive"] }
# The assertions whose subject is a future, for tests on tokio.
dokimi-assert-tokio = { git = "https://github.com/dokimasia/assert-rust" }The crates need Rust 1.85 or later, and they use edition 2024. The features
of dokimi-assert add optional parts:
deriveadds#[derive(prop::Arbitrary)].chrono,jiff,time,uuidandrust_decimalimplementprop::Arbitraryfor the types of those crates.rand_corelets the random source of a property's case serve as arand_core::Rng.
- Use cases
- Getting started
- Two surfaces
- Seats
- The assertions
- Files
- Golden files
- Properties
- Histories
- Machines
- Futures
- Benchmark ceilings
- Equality
- Call records
- The standard
- Development
assert_eq! compares two values, and
pretty_assertions adds a diff
to its failure. Use this library for the checks that Rust has no other way
to state:
- Soft assertions.
assert_eq!stops at the first failure.softrecords each failure and lets the test continue, so one run reports every failure with its line. - Assertions about behaviour. A subject stops when its caller cancels it, leaves state alone, runs without a handle, returns its input through a round trip, and allocates no more than a ceiling allows.
- Files and golden output. A test writes a tree of files into a directory of its own and compares the directory with the tree that it wants. A golden file records output, and the next run compares its output with the file.
- Properties.
propruns a body against generated inputs, shrinks a failing case to the smallest one that fails the same way, and stores it for the next run. - Concurrency.
historychecks a recorded history of concurrent calls for linearizability, serializability and snapshot isolation.statefuldrives a subject through generated steps, on threads or as tasks whose order replays. - The same meaning in every language. Go, Python, TypeScript, Java, Kotlin and Rust implement one definition, so a Rust test and its Go port check the same thing.
use dokimi_assert::{Collector, check};
#[test]
fn trim_removes_the_padding() {
let t = Collector::new();
let got = " widget ".trim();
check::is_not_empty(&t, got, "the trimmed name is not empty");
check::equal(&t, got, "widget", "trim removes the padding");
}Each assertion takes the seat first and the message last. The message is the contract under test, and a failure starts with it:
trim removes the padding: want "widget", got "gadget"
Every assertion has #[track_caller], so the location of a failure is the
line of the test, not a line of the library.
check::rejects takes the message before its body. A golden comparison
takes no message, because the library builds its contract from the path of
the golden file. A benchmark contract takes the message in
Contract::new.
check ends the test at the first failure. soft records the failure and
lets the test continue. Both modules have the same assertions under the
same names, except rejects, which only check has.
use dokimi_assert::{Collector, check, soft};
#[test]
fn the_reply_lists_every_item() {
let t = Collector::new();
let (status, body, items) = (200, "[1,2]", vec![1, 2]);
check::equal(&t, status, 200, "the request succeeds");
soft::has_prefix(&t, body, "{", "the body is JSON");
soft::length(&t, &items, 3, "every item comes back");
}The collector drops at the end of the test and reports both failures, each with its line:
2 failures:
1. the body is JSON: got "[1,2]", prefix "{"
at tests/api.rs:9
2. every item comes back: want 3, got 2
at tests/api.rs:10
An assertion reports each failure to the Seat that the test passes,
instead of calling a test framework or panicking. What a failure does
depends on the seat:
| Seat | A failure on check |
A failure on soft |
|---|---|---|
Collector |
Panics | Is collected, and the collector panics with every collected failure when it drops |
Standard |
Panics | Panics |
Recorder |
Is kept | Is kept |
Collectoris the seat of a test. A collector that drops while its thread unwinds from another panic does not panic again, because a second panic aborts the process.Collector::flushreports the collected failures at a point that the test chooses.Standardis the seat of code without a test, such as a fuzz target. A failure onsoftpanics there too. A collected failure needs the end of a test, and this seat has none.Recorderis the seat of a test of an assertion. Nothing that runs on it fails the test.failed,message,messages,failures,faultsandrecordsreturn what the assertion reported.Recorder::with_clocksets the clock that the timed assertions read, andRecorder::with_cancellationsets the cancellation handle of the test.- A type of your own implements
Seatwithfailandrecord. The trait's default methods return the platform clock and a cancellation handle that never stops, and they render the sentence of each failure.
A fault is an argument or an environment that a call refuses, such as a
pattern that does not compile. The seat receives the fault as the error it
is through Seat::report_fault. A Recorder keeps it, and the default
method sends its text through fail. dokimi_assert::fault ends the call
of a helper of your own the same way:
use dokimi_assert::{Seat, fault};
use std::io;
fn restart(seat: &dyn Seat, restartable: bool) {
if !restartable {
fault(seat, io::Error::other("the service cannot restart"));
return;
}
// …
}check has 49 assertions, and soft has the same names without rejects.
Each signature in this section leaves out seat: &dyn Seat, the first
parameter, and msg: &str, the last.
Equality. equal and not_equal compare with ==, so they accept any
pair of types that PartialEq relates, such as a String and a &str.
check::equal<G, W>(got: G, want: W) where G: PartialEq<W> + Debug, W: Debug
check::not_equal<G, W>(got: G, want: W) where G: PartialEq<W> + Debug, W: DebugTruth and absence.
check::is_true(condition: bool)
check::is_false(condition: bool)
check::is_none<T: Debug>(got: Option<T>)
check::is_some<T: Debug>(got: Option<T>)Size. Each takes a Container: str, String, a slice, an array,
Vec, VecDeque, LinkedList, HashMap, BTreeMap, HashSet,
BTreeSet, or a reference, a Box or an Option of one. The length of
text is its number of Unicode scalar values. None has no length and
fails all three.
check::length<C: Container + ?Sized>(got: &C, want: usize)
check::is_empty<C: Container + ?Sized>(got: &C)
check::is_not_empty<C: Container + ?Sized>(got: &C)Containment. Text contains a substring, a sequence contains an
element, and a map or a set contains a key. is_permutation compares
elements with PartialEq alone. It does not need Ord or Hash.
check::contains<H, N>(haystack: &H, needle: &N) where H: Contains<N> + Debug + ?Sized, N: Debug + ?Sized
check::not_contains<H, N>(haystack: &H, needle: &N) where H: Contains<N> + Debug + ?Sized, N: Debug + ?Sized
check::contains_in_order(got: &str, needles: &[&str])
check::is_permutation<T: PartialEq + Debug>(got: &[T], want: &[T])Text. The patterns of matches are the portable subset of regular
expressions. Every implementation interprets that subset the same way. In
it \d, \w and \s are ASCII classes, . matches no line terminator,
and $ matches at the end of the text alone. A pattern passes when it
matches anywhere in the text.
check::has_prefix(got: &str, prefix: &str)
check::has_suffix(got: &str, suffix: &str)
check::matches(got: &str, pattern: &str)Numbers. Every integer and float type is a Number, so a test passes a
u64 or an f32 without a cast. The tolerance is an absolute difference.
NaN is outside every tolerance and every range.
check::close_to<N: Number>(got: N, want: f64, tolerance: f64)
check::in_range<N: Number>(got: N, low: f64, high: f64)Order. pairwise covers sorted, unique and strictly increasing,
because each of those is a relation between neighbours.
check::pairwise<T: Debug>(items: &[T], predicate: impl Fn(&T, &T) -> bool)Errors. Rust puts failure in the type, so these assertions take a
Result or an error. error_is, error_is_not and error_as search the
chain of Error::source.
check::no_error<T, E: Debug>(got: &Result<T, E>)
check::has_error<T: Debug, E>(got: &Result<T, E>)
check::error_is<T>(error: &(dyn Error + 'static), target: &T) where T: Error + PartialEq + 'static
check::error_is_not<T>(error: &(dyn Error + 'static), target: &T) where T: Error + PartialEq + 'static
check::error_as<'e, T: Error + 'static>(error: &'e (dyn Error + 'static)) -> Option<&'e T>Panics. panics returns the message of the panic. The assertion does
not print a panic that it expects.
check::panics(body: impl FnOnce()) -> Option<String>
check::does_not_panic(body: impl FnOnce())Behaviour. A subject that can stop takes an Option<&Cancel>. Cancel
is the handle that the subject reads to learn that it should stop, and why:
Stop::Cancelled or Stop::DeadlineExceeded. The subject returns the
reason as its error or as the source of its error. Synchronous code reads
Cancel::reason. Asynchronous code awaits Cancel::stopped, which needs no
particular runtime.
use dokimi_assert::{Cancel, Collector, Stop, check};
/// Returns a page, or the reason of the handle when it has stopped.
fn fetch(cancel: Option<&Cancel>) -> Result<String, Stop> {
if let Some(reason) = cancel.and_then(Cancel::reason) {
return Err(reason);
}
Ok("fetched".to_owned())
}
#[test]
fn fetch_stops_when_asked() {
let t = Collector::new();
check::honours_cancellation(&t, |cancel| fetch(cancel).map(drop), "fetch stops on a cancelled handle");
check::honours_deadline(&t, |cancel| fetch(cancel).map(drop), "fetch stops on an expired handle");
check::none_handle_safe(&t, |cancel| fetch(cancel).map(drop), "fetch runs without a handle");
}check::honours_cancellation<E: Error + 'static>(body: impl FnOnce(Option<&Cancel>) -> Result<(), E>)
check::honours_deadline<E: Error + 'static>(body: impl FnOnce(Option<&Cancel>) -> Result<(), E>)
check::completes_within<E: Debug + Send + 'static>(within: Duration, body: impl FnOnce(Option<&Cancel>) -> Result<(), E> + Send + 'static)
check::none_handle_safe<E: Debug>(body: impl FnOnce(Option<&Cancel>) -> Result<(), E>)
check::is_pure<S: PartialEq + Debug>(observe: impl Fn() -> S, body: impl FnOnce())
check::is_not_pure<S: PartialEq + Debug>(observe: impl Fn() -> S, body: impl FnOnce())completes_within runs the body on a thread of its own, with a handle that
expires when the duration passes. The assertion does not wait for a body
that has not returned by then, so the body is Send + 'static.
Relations. Each relation calls the subject with the inputs that the test passes:
check::is_idempotent<I, S, E>(call: impl FnMut(&I) -> Result<(), E>, input: I, observe: impl FnMut() -> S) where S: PartialEq + Debug, E: Debug
check::accumulates<I, E: Debug>(call: impl FnMut(&I) -> Result<(), E>, input: I, observe: impl FnMut() -> i64)
check::is_deterministic<I, O, E>(call: impl FnMut(&I) -> Result<O, E>, input: I) where O: PartialEq + Debug, E: Debug
check::is_commutative<T, R>(combine: impl FnMut(&T, &T) -> R, a: T, b: T) where R: PartialEq + Debug
check::is_associative<T>(combine: impl FnMut(&T, &T) -> T, a: T, b: T, c: T) where T: PartialEq + Debug
check::round_trip<I, O, E, F>(forward: impl FnOnce(&I) -> Result<O, E>, inverse: impl FnOnce(&O) -> Result<I, F>, input: I) where I: PartialEq + Debug, E: Debug, F: Debug
check::has_stable_order<T, E>(iterate: impl FnMut() -> Result<Vec<T>, E>) where T: PartialEq + Debug, E: Debug
check::no_duplicates<T, E>(iterate: impl FnOnce() -> Result<Vec<T>, E>) where T: PartialEq + Debug, E: Debug
check::is_monotonic<N, E>(observe: impl FnMut() -> N, advance: impl FnMut() -> Result<(), E>, steps: usize) where N: PartialOrd + Debug, E: Debug
check::is_total<I, E: Debug>(call: impl FnMut(&I) -> Result<(), E>, domain: &[I])
check::fails_after_close<T, E, C>(close: impl FnOnce() -> Result<(), C>, call: impl FnOnce() -> Result<(), E>, sentinel: &T) where T: Error + PartialEq + 'static, E: Error + 'static, C: Debug
check::is_poisoned<E: Debug>(induce: impl FnOnce(), observe: impl FnMut() -> Result<(), E>)| Assertion | Fails when |
|---|---|
is_idempotent |
A second call with input leaves observe returning other than the first call left it |
accumulates |
The first call leaves the count unchanged, or a second call changes it by another amount |
is_deterministic |
One of 32 calls returns a result other than the first |
is_commutative |
combine(a, b) differs from combine(b, a) |
is_associative |
combine(combine(a, b), c) differs from combine(a, combine(b, c)) |
round_trip |
inverse of forward of input does not return input |
has_stable_order |
One of 32 iterations yields a sequence other than the first |
no_duplicates |
One iteration yields an element twice |
is_monotonic |
The reading of observe falls, or is NaN, while advance runs steps times |
is_total |
call returns an error or panics for an element of domain |
fails_after_close |
call after close does not return an error whose chain contains sentinel |
is_poisoned |
One of 32 readings of observe after induce returns Ok |
Retrying. eventually retries a body, and eventually_true retries a
predicate, until something outside the test makes the condition true or the
timeout passes. Each attempt of eventually runs on a seat of its own, so a
failure ends the attempt and not the test. eventually_true retries with a
backoff that starts at a millisecond and doubles, up to a quarter of the
timeout. Both read the seat's clock. On the platform clock they spend real
time. On a Controlled clock they advance the clock between attempts and do
not spend real time.
check::eventually(timeout: Duration, interval: Duration, body: impl Fn(&dyn Seat))
check::eventually_true(timeout: Duration, predicate: impl Fn() -> bool)Allocations. max_allocs and max_allocs_with_setup end the test
when one call makes more heap allocations than the ceiling allows. They run
in an ordinary test, not in a benchmark.
- The assertion warms the body with one call and counts the allocations of the next 100 calls on the calling thread, so tests on other threads do not change the count.
- A ceiling of 0 admits one allocation in all. Any other ceiling admits an average per call that rounds to at most the ceiling.
- A count above the ceiling is taken again, ten times at most.
max_allocs_with_setupbuilds the input of each call withsetup, and does not count the allocations ofsetup.- Both take their counts from
CountingAllocator, as the benchmark ceilings do.dokimi_assert::allocs_checked()returns the reason when the test binary does not install it.
check::max_allocs(body: impl FnMut(), ceiling: u64)
check::max_allocs_with_setup<T>(setup: impl FnMut() -> T, body: impl FnMut(T), ceiling: u64)Testing an assertion. rejects runs a check on a seat of its own and
returns the check's failures. It ends the test when the check does not fail.
A fault that ends a call of the check ends the call of rejects with the
same fault. A check that calls Seat::skip before it fails ends the call
as skipped, and rejects passes the skip to its seat. Only check has it,
because a check on soft does not stop. Its signature keeps the message,
which comes before the body.
check::rejects(msg: &str, body: impl FnOnce(&dyn Seat)) -> Vec<Failure>files writes trees of files for a test, and checks the files that the code
under test reads and writes.
use dokimi_assert::Collector;
use dokimi_assert::files::{self, Tree};
#[test]
fn a_rename_moves_the_draft() {
let t = Collector::new();
let dir = files::workspace(&t, &Tree::from([("draft.txt", files::text("hello\n"))]));
std::fs::rename(dir.path().join("draft.txt"), dir.path().join("final.txt")).unwrap();
files::equal(&t, &dir, &Tree::from([("final.txt", files::text("hello\n"))]), "the draft becomes the final file");
files::absent(&t, dir.path().join("draft.txt"), "no draft is left");
}files::text,bytes,executable,directoryandlinkbuild the entries of aTree. Each entry is at a path relative to the tree's root, with/between the names.Entry::with_modesets the permission bits of an entry where they are part of the contract.files::workspacewrites a tree into a new directory below the system's temporary directory. TheWorkspaceremoves the directory when it drops.files::writewrites a tree over a directory that exists, andfiles::readreturns the text 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.- A comparison follows no link, and it compares content as bytes. It compares a mode only where the wanted entry has one, and otherwise the owner's execute bit of a file.
- Windows does not record permission bits, so the library does not read a
mode there, and
has_modeends the call with a fault. - Every assertion of
filesends the test at a failure.
The signatures leave out the seat and the message. workspace, write and
read take no message.
files::workspace(tree: &Tree) -> Workspace
files::write(dir: impl AsRef<Path>, tree: &Tree)
files::read(path: impl AsRef<Path>) -> String
files::equal(dir: impl AsRef<Path>, want: &Tree)
files::contains(dir: impl AsRef<Path>, want: &Tree)
files::unchanged(dir: impl AsRef<Path>, body: impl FnOnce())
files::absent(path: impl AsRef<Path>)
files::is_file(path: impl AsRef<Path>)
files::is_dir(path: impl AsRef<Path>)
files::links_to(path: impl AsRef<Path>, target: &str)
files::has_content(path: impl AsRef<Path>, want: impl AsRef<[u8]>)
files::has_mode(path: impl AsRef<Path>, mode: u32)use dokimi_assert::{Collector, golden};
#[test]
fn the_report_is_unchanged() {
let t = Collector::new();
let report = format!("generated at {}\ntotal: 3\n", "2026-10-22T09:30:00Z");
golden::matches(&t, "report.txt", &report, golden::should_update(), &[golden::scrub_timestamps()]);
}The file testdata/golden/report.txt in the package's root contains the
recorded output. DOKIMI_ASSERT_UPDATE_GOLDEN=1 cargo test writes it,
because golden::should_update returns true when the variable is set to
anything other than the empty string and 0. A comparison applies its
scrubbers to both sides before it compares them, so the recorded file
contains generated at SCRUBBED_TIMESTAMP.
The golden comparisons take no message, and each ends the test at a failure. The signatures leave out the seat.
golden::matches(name: &str, got: impl AsRef<str>, update: bool, scrubbers: &[Scrubber])
golden::matches_at(path: impl AsRef<Path>, got: impl AsRef<str>, update: bool, scrubbers: &[Scrubber])
golden::matches_tree(name: &str, got: impl AsRef<Path>, update: bool, scrubbers: &[Scrubber])
golden::matches_json_field(path: impl AsRef<Path>, field: &str, got: impl AsRef<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::new(replace: impl Fn(&str) -> String + Send + Sync + 'static) -> Scrubbermatchesandmatches_treeresolve a name againsttestdata/golden, andmatches_attakes a path.matches_treecompares a directory with a golden directory. An update makes the golden directory equal the output.matches_json_fieldcompares a JSON text with one field of a golden JSON object. A difference of layout passes.
use dokimi_assert::{Collector, check, prop};
#[test]
fn sorting_keeps_every_element() {
let t = Collector::new();
prop::for_all(&t, "sorting keeps every element", prop::Options::default(), |c| {
let mut items = c.draw(&prop::list(prop::integer(0..=100_u8), ..=20), "items");
let before = items.len();
items.sort_unstable();
check::length(c, &items, before, "the sorted list has every element");
});
}prop::for_all runs the body against generated cases. The body takes its
inputs from its prop::Case with Case::draw, and it asserts on the case
as a test asserts on its seat. A failing case shrinks to the smallest case that fails the
same way:
use dokimi_assert::{Collector, check, prop};
#[test]
fn reversing_keeps_the_first_element() {
let t = Collector::new();
prop::for_all(&t, "a reversed list starts with its first element", prop::Options::default(), |c| {
let items = c.draw(&prop::list(prop::integer(0..=9_u8), ..=10), "items");
let reversed: Vec<u8> = items.iter().rev().copied().collect();
check::equal(c, reversed.first(), items.first(), "the reverse starts with the first element");
});
}a reversed list starts with its first element: counterexample after 1 valid and 0 rejected cases, seed 9394425613992845869
items: [0, 1], any value fails
failure of equal at reverse.rs:9: the reverse starts with the first element: want Some(0), got Some(1)
replay: prop::replay("prop1:AAEAAAABAAEAAA") or DOKIMI_ASSERT_PROP_REPLAY=prop1:AAEAAAABAAEAAA
- A failing run writes its smallest case to the store of the test under
testdata/prop, and later runs replay it first. - A form, such as
prop::equalorprop::round_trip, runs one assertion on every generated input. prop::of::<T>()returns the generator of a type that implementsprop::Arbitrary.#[derive(prop::Arbitrary)]implements it for a struct or an enum, andprop::of_shapereturns the generator of a shape file.prop::fuzzreplays the stored cases of a property and returns the target of a fuzzer, such as the body offuzz_target!.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.Case::skip(reason)ends the run of the property as skipped, for an environment that lacks a capability of the body. The property passes the skip to its seat throughSeat::skip, whose default writes the reason to the standard error. libtest has no call that skips a running test, so the test passes. The call record of the property states the verdictskipand the reason.prop::aroundruns each run of the body inside a wrapper, such as a runtime or a frozen clock. A wrapper that does not callrun, calls it a second time or panics ends the call with a fault.prop::using(&generator)makesgeneratorthe generator of its type in one run. A draw ofprop::of::<T>()of that type decodes with it, and so does each value of that type inside a derived type. Insidegenerator, a draw of its own type decodes without the setting, so a run can narrow a type withprop::of::<T>().filter(…).prop::values(&generator, seed, n, options)returns the values of the first random cases of a seed outside a property. A benchmark then measures the same inputs in every run.optionstakesusingalone, so the benchmark and a run of the property draw from one domain. A call without it passesOptions::default().
A form takes a type with a derived generator:
use dokimi_assert::{Collector, prop};
#[derive(Debug, Clone, PartialEq, prop::Arbitrary)]
struct Line {
#[prop(min = 1, max = 99)]
quantity: u32,
sku: String,
}
fn encode(line: &Line) -> Result<String, String> {
Ok(format!("{}:{}", line.quantity, line.sku))
}
fn decode(text: &String) -> Result<Line, String> {
let (quantity, sku) = text.split_once(':').ok_or("no separator")?;
let quantity = quantity.parse().map_err(|_| "no quantity")?;
Ok(Line { quantity, sku: sku.to_owned() })
}
#[test]
fn decoding_undoes_encoding() {
let t = Collector::new();
prop::round_trip(&t, encode, decode, "decode undoes encode", prop::cases(200));
}use dokimi_assert::history::{self, History, Operation, Spec};
use dokimi_assert::{Collector, Value};
/// A register of integers, 0 before the first write.
struct Register;
impl Spec for Register {
type State = i64;
fn initial(&self) -> i64 {
0
}
fn next(&self, state: &i64, op: &Operation) -> Vec<i64> {
match (op.name(), op.args()) {
("write", [Value::Int(value)]) => vec![i64::try_from(*value).unwrap_or_default()],
_ if op.returned(*state) => vec![*state],
_ => Vec::new(),
}
}
}
#[test]
fn the_register_is_linearizable() {
let t = Collector::new();
let calls = History::new();
calls.invoke(0, "write", [Value::from(1)], ["x".into()]).ok(Value::Null);
calls.invoke(1, "read", [], ["x".into()]).ok(0);
history::is_linearizable(&t, &calls, &Register, "the register is linearizable", history::Options::default());
}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:
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.
use dokimi_assert::prop::{self, Case};
use dokimi_assert::stateful::{self, Action, Machine};
use dokimi_assert::{Collector, Value};
use std::sync::atomic::{AtomicI64, Ordering};
#[test]
fn the_register_keeps_its_last_write() {
let t = Collector::new();
prop::for_all(&t, "the register keeps its last write", prop::Options::default(), |c| {
let register = AtomicI64::new(0);
let machine = Machine::new([
Action::with_input(
"write",
|c: &Case<'_>, _: &i64| c.draw(&prop::integer(0..=9_i64), "v"),
async |c: &Case<'_>, client: usize, value: i64| {
let call = c.history().invoke(client, "write", [value.into()], []);
register.store(value, Ordering::SeqCst);
call.ok(Value::Null);
},
),
Action::new("read", async |c: &Case<'_>, client: usize| {
let call = c.history().invoke(client, "read", [], []);
call.ok(register.load(Ordering::SeqCst));
}),
])
.spec(Register);
stateful::steps(c, &machine, stateful::clients(2));
});
}Register is the specification of the previous section.
- A machine has the sequential specification of a subject and its actions,
with the simpler actions first.
Action::newtakes the run of an action, andAction::with_inputtakes the input that the run receives as well. 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 task awaitsScheduler::yield_nowat each point where another task can run.
dokimi-assert-tokio has the assertions whose subject is a future, for
tests that run on tokio. Each returns a future that the test awaits.
use dokimi_assert::Collector;
use dokimi_assert_tokio::check;
use std::time::Duration;
#[tokio::test(start_paused = true)]
async fn the_job_ends_within_a_second() {
let t = Collector::new();
let job = tokio::time::sleep(Duration::from_millis(200));
check::completes_within(&t, Duration::from_secs(1), job, "the job ends within a second").await;
}The signatures leave out the seat and the message.
check::honours_cancellation<'a, E: Error + 'static>(body: impl AsyncFnOnce(Option<&Cancel>) -> Result<(), E> + 'a)
check::honours_deadline<'a, E: Error + 'static>(body: impl AsyncFnOnce(Option<&Cancel>) -> Result<(), E> + 'a)
check::completes_within<'a>(within: Duration, body: impl Future + 'a)
check::eventually<'a>(timeout: Duration, interval: Duration, body: impl AsyncFn(&dyn Seat) + 'a)
check::eventually_true<'a>(timeout: Duration, predicate: impl AsyncFn() -> bool + 'a)
check::no_task_leaks<'a>(body: impl AsyncFnOnce() + 'a)- Each returns
impl Future<Output = ()> + 'a, andsofthas the same six. honours_cancellation,honours_deadline,completes_within,eventuallyandeventually_truehave the names of their synchronous counterparts.no_task_leaksfails when a task that the body spawned on the runtime still runs half a second after the body completes. Rust's standard library cannot list the threads of a process, so the assertion does not see a thread fromstd::thread.- The assertions that wait read tokio's clock. A test that pauses the
clock, as
#[tokio::test(start_paused = true)]does, does not spend real time on a retry or a timeout. completes_withindrops a future that has not completed in time, astokio::time::timeoutdrops it.
The dependencies of a test on tokio are in crates/dokimi-assert-tokio/README.md.
A benchmark that prints numbers reports what happened. With a contract, the test fails when a measurement crosses a ceiling.
use dokimi_assert::Collector;
use dokimi_assert::bench::{Contract, CountingAllocator};
use std::time::Duration;
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator::new();
#[test]
fn parsing_a_port_is_quick() {
let t = Collector::new();
Contract::new(&t, "parsing a port is quick")
.warmup(100)
.max_latency(Duration::from_micros(50))
.max_allocs(0)
.run(10_000, || {
std::hint::black_box("8080".parse::<u16>().unwrap());
})
.check();
}- Each method returns the contract, and nothing is asserted until
checkruns.checkreports each ceiling as one call of its assertion. max_latencysets a ceiling on the p99 latency of one iteration. With fewer than 100 iterations, the ceiling applies to the slowest one.max_meansets a ceiling on the mean latency.max_allocssets a ceiling on the mean allocations of one iteration, rounded to the nearest whole number with a half rounded up.max_bytessets a ceiling on the bytes that one iteration requests from the allocator, rounded down.warmupsets the number of iterations that the run makes before the measured ones.runmeasures a body.measuringbuilds the input of each iteration with a setup closure, outside the measurement.run_parallelruns the body on scoped threads that make the iterations together.- The allocation ceilings take their counts from
CountingAllocator, which the test binary installs as its global allocator. A program has one global allocator, so the library cannot install it for the caller. Without it, an allocation ceiling ends the call with a fault.
equal compares with ==, as assert_eq! does. The definition has three
rules of equality. NaN is unequal to itself, 0.0 equals -0.0, and
containers compare element by element. Rust's floats and collections follow
all three rules, so the library does not add a comparison of its own. A
pair of types that PartialEq does not relate fails to compile.
The definition offers three relaxations, and this library declines all three:
equate-empty: an absent container and an empty one have different types, such asOption<Vec<T>>andVec<T>.equate-nans: a test that needs NaN equal to NaN comparesf64::to_bits.by-identity:PartialEqcompares values, so a test compares two references withstd::ptr::eq.
With DOKIMI_ASSERT_RECORD=1, the seat of each test writes the call record
of every assertion as one line of JSON. The file is beside the test binary
and has the binary's name with the suffix .dokimi-assert.jsonl, such as
target/debug/deps/api-0a1b2c3d4e5f6a7b.dokimi-assert.jsonl.
- Each line contains the definition's version, the sequence number of the call in its test, the assertion, the contract, the verdict, the file and line of the call, and the test. The line of a failure also contains its detail.
- Any other value than the empty string,
0and1makes every call end with a fault. - A
Recorderkeeps its records whatever the variable states, andRecorder::recordsreturns them.
assert-spec defines the
assertions. The unpublished crate dokimi-assert-conformance contains a
copy of the definition. ergon tool run spec.spec -- sync writes the copy
from a commit of assert-spec, and make check-spec verifies it. The
crate's tests run the definition's cases and vectors through the public API
of both crates. They compare JSON with serde_json, never with the library
under test.
- 161 of the corpus's 195 cases apply to Rust. Of the other 34, 7 skip Rust and 27 use a relaxation that the library declines.
- Each case runs on
checkandsoftof each crate that has its assertion. The driver compares the verdict, the failure's sentence and detail, and the call record of each run with the case. - The vectors run too: 57 for files, 80 for histories, 435 for properties and 14 for machines. Two more property vectors skip Rust, because its standard library has no complex or rational type. A platform that does not record permission bits skips the files vectors that state a mode.
- The crates implement all 110 assertions of the definition.
dokimi-assert-tokiohasno_task_leaks, anddokimi-asserthas the others. The spec oflib.rsnames every Rust name of the naming table in code, so the build fails when one is missing.
The overlay, crates/dokimi-assert-conformance/overlays/rust.json, contains
the differences between Rust and the definition. Besides the limits and the
declined relaxations of the earlier sections, these differences include
seven names of the definition's surface that the library replaces:
Reporter,ClockedandFaultReporter: theSeattrait givesreport,clockandreport_faultdefaults, so no second trait is needed.Contract.excluding:Contract::measuringtakes the setup as a closure of its own.assertionandthat: the functions make every check that a chain would make, and a chain would borrow the subject across every method of the chain.cancellation-of:Seat::cancellationreturns the handle of any seat.
The design records of the library and their statuses are in docs/rfc.
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 # every test, then the doctests of the library crates
make lint # rustfmt, clippy with the pedantic lints, and the documentation
make coverage # fails on a line or a function of a published crate that no test ran
make deny # the licences, advisories and sources of the dependencies
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 Rust, it
runs rustfmt, clippy, rustdoc, the tests, a check of each crate on Rust
1.85 and cargo-audit over Cargo.lock. On Linux, it also measures the
coverage and packages each crate. CI runs the gate of Rust on Linux, macOS
and Windows, and cargo-deny on Linux.
MIT. See LICENSE.