Skip to content

Repository files navigation

dokimi-assert

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

CI Licence Java

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.

Getting started

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"

What a seat is

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.

Two surfaces

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

Several assertions about one value

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 time

The 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.

The assertions

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 E

ThrowingthrowsException, 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) -> Runnable

Testing an assertion — On Check only: Soft cannot drive a check to failure.

Check.rejects(Seat seat, String msg, Consumer<Recorder> body) -> String

Golden 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() -> void

Coroutines — 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.

Equality

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

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 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 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.

Where Java differs

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.

Development

./gradlew build           # compile, test, document, jar
./gradlew test
./gradlew javadoc
./gradlew centralBundle   # both artifacts as one Maven Central bundle

Building 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.

Licence

MIT. See LICENSE.

About

Test assertions for Java and Kotlin, 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