Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

dokimi-assert

Test assertions for Rust, defined by a language-neutral standard and held to it on every run.

CILicenceRust

[dev-dependencies]
dokimi-assert = "0.1"dokimi-assert-tokio = "0.1"# the six that take a future

Rust 1.85 and up, edition 2024.

What this is for

assert_eq! is better than this library for comparing two values, and pretty_assertions is better still. Reach for this when you want something Rust has no other way to say:

  • Soft assertions.assert_eq! stops at the first failure. soft records and carries on, so one run reports every property that failed, each with the line it was written on.
  • Assertions about behaviour. Whether a subject honours cancellation, leaves state alone, survives a missing handle, or stays inside an allocation ceiling. Nothing else in the ecosystem asserts these.
  • The same meaning in another language. A Go service and its Rust rewrite can run the same assertions and get the same answers.

Getting started

use dokimi_assert::{check, seat::Collector};#[test]fnget_answers_the_stored_item(){let seat = Collector::new();let item = store.get("widget");
check::is_some(&seat, item.as_ref(),"get answers the stored item");
check::equal(&seat,&item.unwrap().name,"widget","and it is the one stored");}

Every assertion takes a seat first and a message last. The message states the contract under test and is the first line of the failure:

and it is the one stored: want "widget", got "gadget"

The failure points at your line, not at the library, because every assertion carries #[track_caller].

What a seat is

The seat is where a failure goes. Assertions never call a test framework and never panic on their own; they report to whatever seat they are handed. That is what lets one assertion serve a real test, a benchmark, and a test that checks the assertion itself.

Seatcheck doessoft does
Collectorpanicscollects, reported when it is dropped
Standardpanicspanics
Recordercollectscollects

Collector is the one a real test wants. It reports what soft collected when it drops, so nothing has to be called at the end and nothing can be forgotten. A collector already unwinding from another panic stays quiet, because panicking twice aborts the process and the first failure is the one worth reading.

Two surfaces

check stops at the first failure. soft records and carries on.

use dokimi_assert::{check, soft, seat::Collector};let seat = Collector::new();
check::equal(&seat,&reply.status,&200,"the request succeeds");
soft::has_prefix(&seat,&reply.body,"{","the body is JSON");
soft::length(&seat,&reply.items,3,"every item comes back");

If both soft calls fail, both are reported together with their lines:

2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
at tests/api.rs:14
2. every item comes back: want length 3, got 2
at tests/api.rs:15

The assertions

Thirty-three on check and thirty-two on soft, since only check can drive an assertion to failure. Three more compare against a golden file and four state benchmark ceilings, which is forty. The forty-first is no_task_leaks, and it lives in the tokio crate because Rust's standard library cannot count what is running.

Every signature below takes seat: &dyn Seat first and msg: &str last; both are elided here to keep the shapes readable.

Equality. The language's own ==, which is already what the standard asks for.

check::equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)
check::not_equal<T:PartialEq + Debug + ?Sized>(got:&T, want:&T)

Truth and absence. Rust states absence in the type, so there is no typed nil to catch.

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. Anything implementing Container: str, String, slices, Vec, VecDeque, HashMap, BTreeMap, HashSet, BTreeSet. A value with no length does not compile, so it cannot fail at run time.

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. What holding means follows the haystack, decided by the types rather than at run time: text holds a substring, a sequence holds an element, a map holds a key.

check::contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::not_contains<H:Holds<N> + Debug + ?Sized,N:Debug + ?Sized>(haystack:&H, needle:&N)
check::contains_in_order(got:&str, needles:&[&str])

Text.

check::has_prefix(got:&str, prefix:&str)
check::has_suffix(got:&str, suffix:&str)
check::matches(got:&str, pattern:&str)

Numbers. Where exact equality is the wrong question.

check::close_to(got: f64, want:f64, tolerance:f64)
check::in_range(got: f64, low: f64, high:f64)

Errors. Rust states failure in the type, so these read a Result rather than catching anything. Matching walks 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:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_is_not<T:PartialEq + Error + Debug + 'static>(error:&dyn Error, target:&T)
check::error_as<'a,T:Error + 'static>(error:&'a dyn Error) -> Option<&'a T>

Panicking. A panic means a broken invariant. A failure a caller is meant to handle is a Result, and the errors family covers that.

check::panics<F:FnOnce()>(body:F) -> Option<String>
check::does_not_panic<F:FnOnce()>(body:F)

Ordering. One assertion rather than sorted, unique and strictly increasing, because each of those is a relation between neighbours.

check::pairwise<T:Debug,P:Fn(&T,&T) -> bool>(items:&[T], predicate:P)

Behaviour.Cancel is the handle a subject reads to learn it should stop. Rust has nothing like context.Context, and dropping a future is not the equivalent: a subject that stops because it was dropped never chose to stop.

check::honours_cancellation<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::honours_deadline<E:Error + 'static,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::completes_within<E:Debug,F>(within:Duration, body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E>
check::none_handle_safe<E:Debug,F>(body:F)
where F:FnOnce(Option<&Cancel>) -> Result<(),E> + UnwindSafe
check::is_pure<S:PartialEq + Debug,O:Fn() -> S,F:FnOnce()>(observe:O, body:F)

Retrying. For a condition something outside the test makes true. Both spend real time.

check::eventually<F:Fn(&Recorder)>(timeout:Duration, interval:Duration, body:F)
check::eventually_true<P:Fn() -> bool>(timeout:Duration, predicate:P)

Testing an assertion. On check only: soft cannot drive a check to failure, because it does not stop.

check::rejects<F:FnOnce(&Recorder)>(body:F) -> String

Golden files. Recorded output, compared and rewritable with UPDATE_GOLDEN=1.

golden::matches(name:&str, got:&str, scrubbers:&[Scrubber])
golden::matches_at(path:&Path, got:&str, scrubbers:&[Scrubber])
golden::matches_json_field(path:&Path, field:&str, got:&str, 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

Coroutines, from dokimi-assert-tokio, for the six a synchronous signature cannot take. The subject is handed a CancellationToken.

check::honours_cancellation(body).await
check::honours_deadline(body).await
check::completes_within(within:Duration, body:implFuture).await
check::eventually(timeout, interval, body).await
check::eventually_true(timeout, predicate).await
check::no_task_leaks(body).await

Equality

The standard asks that NaN be unequal to itself, that 0.0 equal -0.0, and that containers compare by their elements. Rust's derived PartialEq already answers all three that way, so this library adds no comparison of its own. Values of different types never compare because they do not compile.

That is the one place Rust made the work smaller rather than larger. The Java implementation needed 223 lines to correct Object.equals on those same three points.

Benchmark ceilings

A benchmark that prints numbers tells you what happened. A ceiling tells you whether it was acceptable.

use dokimi_assert::bench::{Contract,CountingAllocator};#[global_allocator]staticALLOC:CountingAllocator = CountingAllocator::new();Contract::new(&seat,"get stays quick").max_latency(Duration::from_millis(2)).max_allocs(4).run(10_000, || { store.get(&id);}).check();

max_allocs and max_bytes need CountingAllocator installed as the test binary's global allocator, and say so rather than passing quietly when it is missing. Rust is the only implementation of this standard that counts allocations exactly: the JVM reports bytes and no count, and V8 answers neither.

The standard

The assertions are defined in assert-spec, language-neutral and implemented in several languages. This library vendors the definition and holds itself to it:

  • 87 corpus cases state what each assertion must report, run against both surfaces. They are the same cases every other implementation runs.
  • A completeness gate names every assertion as a value of its own type. Rust can look nothing up at run time, so a rename or a changed shape fails the build rather than a test.
  • An overlay records what this language supplies only partly.

Rust is the first implementation with nothing absent: 41 of 41. Three are recorded as partial. max_allocs and max_bytes need the allocator installed, and no_task_leaks sees Tokio tasks but not a thread started with std::thread, because Rust's standard library cannot enumerate threads at all.

docs/rfc/0001 records what Rust does differently from the other implementations, and why.

Development

make check # fmt, clippy, build, test, doc
make test
make msrv # build on the declared 1.85 floor

Licence

MIT. See LICENSE.

About

Test assertions for Rust, defined by a language-neutral standard and held to it on every run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages