Test assertions for Java and Kotlin, defined by a language-neutral standard and held to it on every run.
dependencies {
testImplementation("dev.dokimi:assert-core:0.1.0")
testImplementation("dev.dokimi:assert-kotlin:0.1.0") // coroutines
}Java 17 and up. No runtime dependencies beyond JSpecify's annotations.
class StoreTest {
@RegisterExtension final SeatExtension seat = new SeatExtension();
@Test
void get() {
var item = store.get("widget");
Check.isNotNull(seat, item, "get answers the stored item");
Check.equal(seat, item.name(), "widget", "and the item is the one stored");
}
}Every assertion takes the seat first and a message last. The message states the contract under test and is the first line of the failure:
AssertionFailed: and the item is the one stored: want "widget", got "gadget"
The seat is where a failure goes. Assertions never call a test framework and never throw 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.
| Seat | Check does |
Soft does |
|---|---|---|
Collector, from SeatExtension |
throws | collects, thrown when the test ends |
Standard |
throws | throws |
Recorder |
collects | collects |
SeatExtension is a Seat itself, so it goes straight into a call.
It is a field rather than a parameter, because a parameter resolver
hands out a value JUnit then forgets: nothing would be left holding the
collector when the body ends. It is the only class here that mentions
JUnit, and it is optional.
Check stops at the first failure. Soft records and carries on, so
one run shows every property that failed.
Check.equal(seat, reply.status(), 200, "the request succeeds");
Soft.hasPrefix(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:
AssertionFailed: 2 failures:
1. the body is JSON: "[1,2]" does not start with "{"
2. every item comes back: expected length 3, got 2
that starts a chain, so a value is named once and every method after
it answers the chain:
Check.that(seat, reply.status())
.notEqual(0, "the status was set")
.equal(200, "the request succeeds");The chain fixes the value's type, so want is held to it and a
mismatch is a compile error:
Check.that(seat, reply.body()).equal(200, "..."); // does not compile
Check.equal(seat, reply.body(), 200, "..."); // compiles, fails at run timeThe static form cannot be tightened the same way. Java infers a common
supertype for two arguments of a generic method, so equal(seat, "1", 1, ...) would still compile whatever the parameters were declared as.
A chain from Check stops at the first failing method. One from Soft
runs them all and reports each failure.
Thirty-four on Check and thirty-three on Soft, since only Check
can drive an assertion to failure. Three more compare against a golden
file, and the benchmark contract states three ceilings. that starts a
chain over any of the value assertions.
Every assertion takes the seat first and the message last.
Check and Soft carry the same names and the same signatures;
only what happens on a failure differs.
Equality — Structural, and strict about types.
Check.equal(Seat seat, Object got, Object want, String msg, Option... options)
Check.notEqual(Seat seat, Object got, Object want, String msg, Option... options)Truth and absence — Java has one null, so this is simpler than the JavaScript column.
Check.isTrue(Seat seat, boolean condition, String msg)
Check.isFalse(Seat seat, boolean condition, String msg)
Check.isNull(Seat seat, Object got, String msg)
Check.isNotNull(Seat seat, Object got, String msg)Size — A CharSequence, a Collection, a Map or an array.
Check.length(Seat seat, Object got, int want, String msg)
Check.isEmpty(Seat seat, Object got, String msg)
Check.isNotEmpty(Seat seat, Object got, String msg)Containment — What holding means follows the haystack.
Check.contains(Seat seat, Object haystack, Object needle, String msg, Option... options)
Check.notContains(Seat seat, Object haystack, Object needle, String msg, Option... options)
Check.containsInOrder(Seat seat, Object got, String[] needles, String msg)Text — CharSequence.
Check.hasPrefix(Seat seat, Object got, String prefix, String msg)
Check.hasSuffix(Seat seat, Object got, String suffix, String msg)
Check.matches(Seat seat, Object got, String pattern, String msg)Numbers — Where exact equality is the wrong question.
Check.closeTo(Seat seat, Object got, double want, double tolerance, String msg)
Check.inRange(Seat seat, Object got, double low, double high, String msg)Errors — For code that hands an exception back. Matching follows the cause chain.
Check.noError(Seat seat, Throwable error, String msg)
Check.hasError(Seat seat, Throwable error, String msg)
Check.errorIs(Seat seat, Throwable error, Object target, String msg)
Check.errorIsNot(Seat seat, Throwable error, Object target, String msg)
Check.errorAs(Seat seat, Throwable error, Class<E> want, String msg) -> @Nullable EThrowing — throwsException, because throws is a keyword.
Check.throwsException(Seat seat, Raises.Body body, String msg) -> @Nullable Throwable
Check.doesNotThrow(Seat seat, Raises.Body body, String msg)Ordering — Sorted, unique, and anything else that holds between neighbours.
Check.pairwise(Seat seat, List<T> items, BiPredicate<T, T> predicate, String msg)Cancellation — Interruption is Java's cancellation: what sleep, wait and take respond to.
Check.honoursCancellation(Seat seat, Behaviour.Cancellable body, String msg)
Check.honoursDeadline(Seat seat, Behaviour.Cancellable body, String msg)
Check.completesWithin(Seat seat, Duration within, Raises.Body body, String msg)Purity and a missing handle — What observe answers defines what nothing means.
Check.isPure(Seat seat, Callable<Object> observe, Raises.Body body, String msg, Option... options)
Check.nullHandleSafe(Seat seat, Behaviour.Handled body, String msg)Retrying — For a condition something outside the test makes true. Both spend real time.
Check.eventually(Seat seat, Duration timeout, Duration interval, Consumer<Seat> body, String msg)
Check.eventuallyTrue(Seat seat, Duration timeout, BooleanSupplier predicate, String msg)Concurrency — Reads the live non-daemon threads either side of the scope.
Check.noTaskLeaks(Seat seat, String msg) -> RunnableTesting an assertion — On Check only: Soft cannot drive a check to failure.
Check.rejects(Seat seat, String msg, Consumer<Recorder> body) -> StringGolden files — recorded output, compared and rewritable.
Golden.shouldUpdate() -> boolean
Golden.scrubTimestamps() -> Scrubber
Golden.scrubHashes() -> Scrubber
Golden.scrubRunIds() -> Scrubber
Golden.scrubJsonFields(String... fields) -> Scrubber
Golden.matchAt(Seat seat, Path path, String got, boolean update, Scrubber... scrubbers)
Golden.match(Seat seat, String name, String got, boolean update, Scrubber... scrubbers)
Golden.matchJsonField(Seat seat, Path path, String field, String got, boolean update, Scrubber... scrubbers)Benchmark ceilings — chained onto one contract.
new Contract(Seat seat, String msg)
Contract.maxLatency(Duration ceiling) -> Contract
Contract.maxMean(Duration ceiling) -> Contract
Contract.maxBytes(long ceiling) -> Contract
Contract.loop(int iterations, Raises.Body body) -> Contract
Contract.check() -> voidCoroutines — from dokimi-assert-kotlin, for the six a Java signature
cannot reach.
Check.honoursCancellation(seat: Seat, msg: String, body: suspend () -> Unit)
Check.honoursDeadline(seat: Seat, msg: String, body: suspend () -> Unit)
Check.completesWithin(seat: Seat, within: Duration, msg: String, body: suspend () -> Unit)
Check.eventually(seat: Seat, timeout: Duration, interval: Duration, msg: String, body: suspend (Seat) -> Unit)
Check.eventuallyTrue(seat: Seat, timeout: Duration, msg: String, predicate: suspend () -> Boolean)
Check.noTaskLeaks(seat: Seat, msg: String, body: suspend (CoroutineScope) -> Unit)Each one carries a doc comment: what it states, what every argument means, the edge cases it decides, and a worked call.
Object.equals answers a different question in three places, and this
corrects all three:
| Expression | equals |
Here |
|---|---|---|
Double.valueOf(NaN).equals(NaN) |
true |
not equal, per IEEE 754 |
Double.valueOf(0.0).equals(-0.0) |
false |
equal |
new int[]{1}.equals(new int[]{1}) |
false |
equal |
Integer.valueOf(1).equals(1L) |
false |
not equal, and for the right reason |
Comparison is structural and reaches arrays, collections and maps, including an array nested inside a list that otherwise compares by value. Different classes never compare, and a cycle stops the walk.
Pass Option.EQUATE_NANS or Option.EQUATE_EMPTY to relax either for
one call. An option applies to the call it is passed to and nothing
else.
Kotlin calls the Java artifact for thirty-five of the forty-one. The
other six take work that suspends, and a Kotlin suspend lambda
compiles to a method taking a hidden Continuation, so no Java method
can accept one. dokimi-assert-kotlin supplies those:
class WorkerTest {
@Test
fun `it stops when told`() = runTest {
Check.honoursCancellation(seat, "the worker stops when told") {
worker.serve()
}
}
}Cancellation there is a coroutine's own: a Job cancelled and a
CancellationException raised at the next suspension point. In the
Java artifact it is Thread.interrupt, which is what sleep, wait,
take and every blocking call in java.util.concurrent respond to.
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 checks every assertion is present under the name the naming table gives it.
- An overlay records what this language cannot supply, and what a check cannot see.
bench.Contract.maxAllocs is declared rather than implemented. The JVM
reports bytes allocated per thread and no count of allocations; JFR
samples allocation events rather than counting them. maxBytes is
implemented, and holds a ceiling on the same behaviour by weight:
ThreadMXBean.getThreadAllocatedBytes counts what a thread allocated
rather than what survived a collection, so the reading does not move
with the collector.
noTaskLeaks sees platform threads and executor threads. A leaked
virtual thread is not reported, because virtual threads appear in no
standard enumeration on any JVM version. The overlay records that as a
limit rather than leaving it to be discovered.
throwsException, because throws is a keyword. It takes a Body
rather than a Runnable, so a caller need not wrap a method that
declares a checked exception.
./gradlew build # compile, test, document, jar
./gradlew test
./gradlew javadoc
./gradlew centralBundle # both artifacts as one Maven Central bundleBuilding needs JDK 23 or newer, because the doc comments are Markdown
and older javadoc reads /// as an ordinary comment. The artifact
targets Java 17 regardless, and CI runs the tests on a real 17.
Pushing a v* tag builds a signed bundle and uploads it to the Central
Portal, which validates it and waits for someone to release it. The
build signs only when SIGNING_KEY and SIGNING_PASSWORD are in the
environment, so an ordinary build needs no key.
docs/rfc/0001 records what Java does differently from the other implementations of this standard, and why.
MIT. See LICENSE.