Testing With hkj-test: Fluent Assertions for HKJ Types

What You'll Learn

  • How to add hkj-test to a project as a test-scope dependency
  • The shape of the assertion API for the simple types, the effect types, and the monad transformers
  • How to assert on chained, lazy, or stateful HKJ values without unwrapping by hand
  • How to drop into Java 25's import module syntax to bring every helper into scope in one line

hkj-test is a small, focused companion to hkj-core: an AssertJ-flavoured assertion module covering every HKJ type a test is likely to touch. It is independent of hkj-checker and the Gradle/Maven plugins; you can adopt it without changing anything else in your build.


Adding the Dependency

hkj-test is published alongside the rest of Higher-Kinded-J on Maven Central.

Gradle

dependencies {
    testImplementation("io.github.higher-kinded-j:hkj-test:LATEST_VERSION")
}

Maven

<dependency>
    <groupId>io.github.higher-kinded-j</groupId>
    <artifactId>hkj-test</artifactId>
    <version>LATEST_VERSION</version>
    <scope>test</scope>
</dependency>

The artifact pulls AssertJ in transitively via requires transitive, so consumers do not need to add it again. It also requires transitive org.higherkindedj.core, which means a single dependency declaration is enough whether you intend to test against Either, IO, or any of the transformers.


What Is Covered

Every public HKJ type a user is likely to assert on has a dedicated assertion class. All assertions live in org.higherkindedj.hkt.assertions.

CategoryHelpers
Discriminated unionsEitherAssert, MaybeAssert, TryAssert, ValidatedAssert, LazyAssert
Reader / Writer / StateReaderAssert (with ReaderResultAssert), WriterAssert, StateAssert
Effect typesIOAssert, VTaskAssert, VStreamAssert
Monad transformersEitherTAssert, MaybeTAssert, OptionalTAssert, ReaderTAssert, StateTAssert, WriterTAssert
Free algebraFreeAssert, EitherFAssert

Each entry point follows the AssertJ convention assertThatXxx(actual):

assertThatEither(result);
assertThatMaybe(value);
assertThatTry(computation);

Assertions for the Simple Types

The discriminated-union and value-bearing types share a common shape: a state predicate, a value-equality check, a value-satisfies-consumer escape hatch, and the usual null variants.

EitherAssert

import static org.higherkindedj.hkt.assertions.EitherAssert.assertThatEither;

Either<DomainError, Order> result = orderService.process(request);

assertThatEither(result)
    .isRight()
    .hasRight(expectedOrder);

assertThatEither(failure)
    .isLeft()
    .hasLeftSatisfying(error ->
        assertThat(error).isInstanceOf(ValidationFailure.class));

MaybeAssert and ValidatedAssert

assertThatMaybe(lookup).isJust().hasValue("alice");
assertThatMaybe(lookup).isNothing();

assertThatValidated(form)
    .isInvalid()
    .hasErrorSatisfying(errors -> errors.size() == 2, "two errors collected");

LazyAssert

Lazy carries its own evaluation lifecycle, so the assertions track it explicitly:

Lazy<Integer> deferred = Lazy.defer(() -> compute());

assertThatLazy(deferred).isNotEvaluated();
assertThatLazy(deferred).whenForcedHasValue(42).isEvaluated();
assertThatLazy(failing).whenForcedThrows(IllegalStateException.class);

WriterAssert and StateAssert

These cover both halves of the wrapped pair:

assertThatWriter(writer)
    .hasValue(42)
    .hasLog("computed: ");

assertThatStateTuple(result)
    .hasValue("processed")
    .hasState(5);

Assertions for the Effect Types

Effect types are lazy, so the assertions take care of running them. IOAssert and VTaskAssert follow a whenExecuted() / whenRun() chain; VStreamAssert materialises the stream once and lets you make repeated claims about it.

IOAssert

import static org.higherkindedj.hkt.assertions.IOAssert.assertThatIO;

IO<Integer> effect = IO.delay(() -> 1 + 1);

assertThatIO(effect).whenExecuted().hasValue(2);
assertThatIO(effect).isNotExecutedYet();
assertThatIO(effect).isRepeatable();

IO<String> failing = IO.delay(() -> { throw new IllegalStateException("kaboom"); });

assertThatIO(failing)
    .throwsException(IllegalStateException.class)
    .withMessageContaining("kaboom");

VTaskAssert

VTask<Integer> task = VTask.delay(() -> heavyComputation());

assertThatVTask(task)
    .whenRun()
    .succeeds()
    .hasValue(expected)
    .completesWithin(Duration.ofSeconds(1));

VStreamAssert

VStream<Integer> stream = VStream.fromList(List.of(1, 2, 3));

assertThatVStream(stream).producesElements(1, 2, 3);
assertThatVStream(stream).hasCount(3);
assertThatVStream(stream).isEmpty();          // for empty streams

assertThatVStream(failingStream)
    .failsWithExceptionType(IllegalStateException.class);

Assertions for Monad Transformers

Transformer assertions take an extra argument: an unwrapper function that pulls the transformer's outer monad back into a plain Optional so the assertion can introspect it. The pattern mirrors how transformer code typically composes outer and inner monads.

import static org.higherkindedj.hkt.assertions.EitherTAssert.assertThatEitherT;
import static org.higherkindedj.hkt.either_t.EitherTKindHelper.EITHER_T;
import static org.higherkindedj.hkt.optional.OptionalKindHelper.OPTIONAL;

OptionalMonad outerMonad = OptionalMonad.INSTANCE;

private <E, A> Optional<Either<E, A>> unwrap(Kind<OptionalKind.Witness, Either<E, A>> kind) {
    return OPTIONAL.narrow(kind);
}

Kind<EitherTKind.Witness<OptionalKind.Witness, String>, Integer> kind =
    EITHER_T.widen(EitherT.right(outerMonad, 42));

assertThatEitherT(kind, this::unwrap)
    .isPresentRight()
    .hasRightValue(42);

The same shape applies to MaybeTAssert, OptionalTAssert, ReaderTAssert, StateTAssert, and WriterTAssert. The Reader, State, and Writer variants additionally provide whenRunWith(env) / whenRunWith(initialState) to drive the underlying computation before asserting.


Java 25: One-line Module Import

hkj-test is published as a proper JPMS module named org.higherkindedj.test. On Java 25 with --enable-preview (already enabled across the HKJ project), JEP 511's module-import syntax reduces the per-class import boilerplate to a single line:

import module org.higherkindedj.test;
import module org.higherkindedj.core;   // brings in Either, Maybe, Try, IO, ...

class UserServiceTest {
    @Test
    void returns_user() {
        Either<DomainError, User> result = userService.findById("u1");
        EitherAssert.assertThatEither(result).isRight();
    }
}

Both modules are now in scope; no further imports are required.


Coverage Guarantees

Every public assertion method on every assertion class is covered by a dedicated *AssertContractTest in hkj-test/src/test/java. Each contract spec enumerates rows of (label, passingInput, failingInput, chain) and the framework dispatches each row as two dynamic tests: one verifying the chain succeeds on the passing input, another verifying it throws AssertionError on the failing input.

Coverage is enforced at 100% line and 100% instruction on the hkj-test bundle. Adding a new assertion method without a corresponding contract row will fail the project's check task, so the test surface stays exhaustive over time.


See Also


Previous: Claude Code Skills