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.
classStoreTest {
@RegisterExtensionfinalSeatExtensionseat = newSeatExtension();
@Testvoidget() {
varitem = 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 compileCheck.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(Seatseat, Objectgot, Objectwant, Stringmsg, Option... options)
Check.notEqual(Seatseat, Objectgot, Objectwant, Stringmsg, Option... options)Truth and absence — Java has one null, so this is simpler than the JavaScript column.
Check.isTrue(Seatseat, booleancondition, Stringmsg)
Check.isFalse(Seatseat, booleancondition, Stringmsg)
Check.isNull(Seatseat, Objectgot, Stringmsg)
Check.isNotNull(Seatseat, Objectgot, Stringmsg)Size — A CharSequence, a Collection, a Map or an array.
Check.length(Seatseat, Objectgot, intwant, Stringmsg)
Check.isEmpty(Seatseat, Objectgot, Stringmsg)
Check.isNotEmpty(Seatseat, Objectgot, Stringmsg)Containment — What holding means follows the haystack.
Check.contains(Seatseat, Objecthaystack, Objectneedle, Stringmsg, Option... options)
Check.notContains(Seatseat, Objecthaystack, Objectneedle, Stringmsg, Option... options)
Check.containsInOrder(Seatseat, Objectgot, String[] needles, Stringmsg)Text — CharSequence.
Check.hasPrefix(Seatseat, Objectgot, Stringprefix, Stringmsg)
Check.hasSuffix(Seatseat, Objectgot, Stringsuffix, Stringmsg)
Check.matches(Seatseat, Objectgot, Stringpattern, Stringmsg)Numbers — Where exact equality is the wrong question.
Check.closeTo(Seatseat, Objectgot, doublewant, doubletolerance, Stringmsg)
Check.inRange(Seatseat, Objectgot, doublelow, doublehigh, Stringmsg)Errors — For code that hands an exception back. Matching follows the cause chain.
Check.noError(Seatseat, Throwableerror, Stringmsg)
Check.hasError(Seatseat, Throwableerror, Stringmsg)
Check.errorIs(Seatseat, Throwableerror, Objecttarget, Stringmsg)
Check.errorIsNot(Seatseat, Throwableerror, Objecttarget, Stringmsg)
Check.errorAs(Seatseat, Throwableerror, Class<E> want, Stringmsg) -> @NullableEThrowing — throwsException, because throws is a keyword.
Check.throwsException(Seatseat, Raises.Bodybody, Stringmsg) -> @NullableThrowableCheck.doesNotThrow(Seatseat, Raises.Bodybody, Stringmsg)Ordering — Sorted, unique, and anything else that holds between neighbours.
Check.pairwise(Seatseat, List<T> items, BiPredicate<T, T> predicate, Stringmsg)Cancellation — Interruption is Java's cancellation: what sleep, wait and take respond to.
Check.honoursCancellation(Seatseat, Behaviour.Cancellablebody, Stringmsg)
Check.honoursDeadline(Seatseat, Behaviour.Cancellablebody, Stringmsg)
Check.completesWithin(Seatseat, Durationwithin, Raises.Bodybody, Stringmsg)Purity and a missing handle — What observe answers defines what nothing means.
Check.isPure(Seatseat, Callable<Object> observe, Raises.Bodybody, Stringmsg, Option... options)
Check.nullHandleSafe(Seatseat, Behaviour.Handledbody, Stringmsg)Retrying — For a condition something outside the test makes true. Both spend real time.
Check.eventually(Seatseat, Durationtimeout, Durationinterval, Consumer<Seat> body, Stringmsg)
Check.eventuallyTrue(Seatseat, Durationtimeout, BooleanSupplierpredicate, Stringmsg)Concurrency — Reads the live non-daemon threads either side of the scope.
Check.noTaskLeaks(Seatseat, Stringmsg) -> RunnableTesting an assertion — On Check only: Soft cannot drive a check to failure.
Check.rejects(Seatseat, Stringmsg, Consumer<Recorder> body) -> StringGolden files — recorded output, compared and rewritable.
Golden.shouldUpdate() -> booleanGolden.scrubTimestamps() -> ScrubberGolden.scrubHashes() -> ScrubberGolden.scrubRunIds() -> ScrubberGolden.scrubJsonFields(String... fields) -> ScrubberGolden.matchAt(Seatseat, Pathpath, Stringgot, booleanupdate, Scrubber... scrubbers)
Golden.match(Seatseat, Stringname, Stringgot, booleanupdate, Scrubber... scrubbers)
Golden.matchJsonField(Seatseat, Pathpath, Stringfield, Stringgot, booleanupdate, Scrubber... scrubbers)Benchmark ceilings — chained onto one contract.
newContract(Seatseat, Stringmsg)
Contract.maxLatency(Durationceiling) -> ContractContract.maxMean(Durationceiling) -> ContractContract.maxBytes(longceiling) -> ContractContract.loop(intiterations, Raises.Bodybody) -> ContractContract.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:
classWorkerTest {
@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.