The generators work with Kotlin classes as they work with Java beans. Java tests need nothing else. Kotlin tests need the processors run by KAPT, the annotation processing tool of Kotlin, and the generated Kotlin DSL is written by KAPT only. The examples here come from example-maven-kotlin, which runs KAPT for its main sources, and from the end-to-end module hamcrest-matcher-generator-endtoend-plain-kotlin, which runs KAPT for its test sources.
Requirements
-
Java 17 or newer, as for every other project.
-
kotlin-stdlibon the classpath of the Kotlin compilation. The generator artifacts do not bring it in. -
A Kotlin compiler that runs KAPT. The generated DSL uses only Kotlin 1.9 language features. The project builds its examples with Kotlin 2.1; older compilers are not tested.
If your project must keep the Kotlin 1.9 language level, set languageVersion and apiVersion to 1.9 on the kotlin-maven-plugin, as the end-to-end module hamcrest-matcher-generator-endtoend-plain-kotlin does:
<!-- The generated DSL promises Kotlin 1.9 language features only (spec 104). -->
<languageVersion>1.9</languageVersion>
<apiVersion>1.9</apiVersion>
Dependencies and processor path
Add the generator artifacts for the backends you use and the Kotlin DSL artifact. The DSL artifact belongs in two places, and both are needed:
-
On the classpath of the Kotlin sources, as a dependency. The generated DSL uses the
@MatcherDslmarker and the runtime classes of that artifact, and kotlinc must resolve them. -
On the KAPT processor path. The artifact writes the Kotlin DSL files, so KAPT must run it.
The example declares the dependency once, in the compile scope, and lists the three processors on the processor path of the kapt execution:
<dependency>
<groupId>io.github.marmer.testutils</groupId>
<artifactId>hamcrest-matcher-generator</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.github.marmer.testutils</groupId>
<artifactId>assertj-assertion-generator</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.github.marmer.testutils</groupId>
<artifactId>hamcrest-matcher-generator-kotlin</artifactId>
<version>${project.version}</version>
</dependency>
KAPT runs the three processors over the Kotlin sources. It writes the Java matchers below target/generated-sources/kapt/compile and the Kotlin DSL files below target/generated-sources/kaptKotlin/compile:
<!-- KAPT runs the processors over the Kotlin sources. The Java matchers, the assertions and the
Kotlin DSL (*MatcherDsl.kt, written to kapt.kotlin.generated) all land in target/generated-sources. -->
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<correctErrorTypes>true</correctErrorTypes>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
</sourceDirs>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>io.github.marmer.testutils</groupId>
<artifactId>hamcrest-matcher-generator</artifactId>
<version>${project.version}</version>
</annotationProcessorPath>
<annotationProcessorPath>
<groupId>io.github.marmer.testutils</groupId>
<artifactId>assertj-assertion-generator</artifactId>
<version>${project.version}</version>
</annotationProcessorPath>
<annotationProcessorPath>
<groupId>io.github.marmer.testutils</groupId>
<artifactId>hamcrest-matcher-generator-kotlin</artifactId>
<version>${project.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
The gotchas of this setup:
-
With the DSL artifact on the processor path only, kotlinc fails on the generated DSL files with
Unresolved reference: MatcherDsl, at the@MatcherDslannotation and at the import of the marker. Add the artifact as a dependency, as above. -
A dependency in test scope alone is not enough for the main sources. KAPT also finds processors on the test classpath, so the DSL can appear for the tests, but the main Kotlin sources still need the artifact on their compile classpath.
Configure the types
The configuration is the same annotation as in Java. It goes on any class, here in the main sources:
import io.github.marmer.matchergenerator.annotations.GenerateMatchers
/** Generates the matchers, the assertions and the Kotlin DSL for every model class in `example.kotlin.model`. */
@GenerateMatchers("example.kotlin.model")
class KotlinConfiguration
Source directories of the compilation
The kotlin-maven-plugin compiles the Kotlin sources and the KAPT output together. Two gotchas follow from that:
-
The
sourceDirsof an execution replace the default source directories. The example lists the KAPT output of the Java matchers (kapt/compile) and of the Kotlin DSL (kaptKotlin/compile) next to the Kotlin sources, so nothing depends on the defaults. The plugin registers the two directories itself, so a build passes without them too, and a "Duplicate source root" warning shows either way. -
Javac runs after kotlinc and gets no processors, because KAPT has generated everything already. The example therefore switches javac’s annotation processing off and keeps every other lint category fatal.
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.build.directory}/generated-sources/kapt/compile</sourceDir>
<sourceDir>${project.build.directory}/generated-sources/kaptKotlin/compile</sourceDir>
</sourceDirs>
Kotlin test sources
Kotlin tests run KAPT over the test sources with the test-kapt goal. The KAPT output of the tests has to reach the Kotlin and the javac test compilation, and the end-to-end module hamcrest-matcher-generator-endtoend-plain-kotlin shows the three pieces that do it:
-
the
test-kaptexecution, with the processor path of the Hamcrest backend and the Kotlin DSL:<execution> <id>test-kapt</id> <goals> <goal>test-kapt</goal> </goals> <configuration> <correctErrorTypes>true</correctErrorTypes> <sourceDirs> <sourceDir>src/main/kotlin</sourceDir> <sourceDir>src/test/kotlin</sourceDir> </sourceDirs> <annotationProcessorPaths> <annotationProcessorPath> <groupId>io.github.marmer.testutils</groupId> <artifactId>hamcrest-matcher-generator</artifactId> <version>${project.version}</version> </annotationProcessorPath> <annotationProcessorPath> <groupId>io.github.marmer.testutils</groupId> <artifactId>hamcrest-matcher-generator-kotlin</artifactId> <version>${project.version}</version> </annotationProcessorPath> </annotationProcessorPaths> </configuration> </execution> -
the
test-compileexecution, whose source directories include the KAPT output of the tests (kapt/testandkaptKotlin/test):<sourceDirs> <sourceDir>${project.basedir}/src/test/kotlin</sourceDir> <sourceDir>${project.build.directory}/generated-sources/kapt/test</sourceDir> <sourceDir>${project.build.directory}/generated-sources/kaptKotlin/test</sourceDir> </sourceDirs> -
the
build-helper-maven-plugin, which registers the KAPT test output as a test source root, so javac compiles every generated matcher:<execution> <id>add-kapt-test-sources</id> <phase>generate-test-sources</phase> <goals> <goal>add-test-source</goal> </goals> <configuration> <sources> <source>${project.build.directory}/generated-sources/kapt/test</source> </sources> </configuration> </execution>
A configuration class in your test sources writes its generated Java to target/generated-test-sources/test-annotations, as in Java. The end-to-end module keeps its configuration in src/test/kotlin and its test-compile execution lists the Kotlin test sources and the KAPT output, not that directory. A Kotlin test compilation that needs that directory has to list it as a source directory itself. This is an assumption: no build in the repository exercises that case.
IntelliJ IDEA with Kotlin tests
IntelliJ IDEA does not know where KAPT puts the generated test sources, and KAPT cannot be told another output path. The known workaround moves the generated test sources to a location the IDE knows, right after KAPT has written them, with the maven-antrun-plugin (version 3.0.0 in the former README). The drawback is that the project must be built with Maven before the IDE sees fresh generated code. Java tests need none of this, see the IDE setup chapter.
Assumption, not covered by a build: the workaround is carried over from the 5.x README. An attempt to run it in the end-to-end module showed that the moved matchers are not on the Kotlin test-compile source path, so the Kotlin tests then fail with unresolved references to the generated matchers. Adding the moved directory to the test-compile source directories was not tried, so do not assume it fixes the failure.
Your first Kotlin test
The Hamcrest matchers, the AssertJ assertions and the Kotlin DSL are used from Kotlin like from Java. A basic assertion checks one property by equality, in the DSL inside a scope:
assertThat(ada, isCustomer { name("Ada") })
The AssertJ assertion is the same as in Java, with Kotlin’s lambda syntax:
CustomerAssert.assertThat(ada)
.hasName("Ada")
.hasAddressSatisfying { AddressAssert.assertThat(it).hasCity("Paris") }
The Kotlin DSL
The DSL is a Kotlin layer on the Hamcrest matchers. For every configured type it writes a file <Type>MatcherDsl.kt in the package of the Java matcher. The file has an entry function isCustomer { } and a scope class CustomerSpec. The scope class carries the @MatcherDsl marker of the DSL, so inside a nested scope you cannot call the functions of an outer scope without naming its receiver.
A scope has these calls:
-
name(value)andname(matcher)for every property, as the HamcrestwithNametakes them; -
ignoringName()for every property, the same as the HamcrestignoringName(); -
checkingAllProperties(), which turns the completeness check on; -
address { }for a property whose type is a configured type that is compared deeply, that is neither an enum nor a type without properties. The nested scope has its own calls.
A nested scope for address checks the nested properties, as in this example:
assertThat(ada, isCustomer {
address { city("Paris") }
})
The completeness check reads the same in the DSL. ignoringActive() counts active as addressed:
assertThat(ada, isCustomer {
checkingAllProperties()
name("Ada")
address { city("Paris") }
ignoringActive()
})
Every scope delegates to the generated Java matcher, so the DSL and the Hamcrest matcher print the same mismatch messages, described in the mismatch messages chapter.
The DSL follows these naming rules:
-
Kotlin keywords as property names are escaped with backticks, so a property
inis checked by.in("inside") -
A property whose name clashes with a member of the scope (
checkingAllProperties,delegate,block,matcher) or with a member ofAny(equals,hashCode,toString) gets no DSL function. The compiler reports a warning, and the Java matcher keeps the property. -
A nested type whose simple name another type of its package shares gets its enclosing type names in front, so
Outer.InnerbecomesOuterInnerSpecandisOuterInner. If that still collides, the processor reports an error at the annotation and writes no scope for the colliding types. -
Collection and map properties use the runtime collection matchers,
itemsInOrder,itemsInAnyOrderandentries, with the DSL entry functions as their items. The DSL has no collection syntax of its own. -
A type without properties gets no DSL file, although its Java matcher exists.
Without KAPT the DSL is not written. Under plain javac the processor prints one note and skips it, so a build without KAPT still compiles the Java matchers.
Property names and getters
The processor reads a Kotlin class through its getter methods, in the same way as a Java bean. The rules below are the ones the core’s detection tests pin (CoreProcessorIT, the Kotlin cases) and the end-to-end module hamcrest-matcher-generator-endtoend-plain-kotlin checks.
-
A getter of a Kotlin class counts with any return type.
getEnabled(): boolean, which the Java bean rules skip, is the propertyenabled, with the matcher methodwithEnabled. Java types keep the Java bean rules. -
An
isgetter that returns the boxedBooleankeeps its full name:isX(): Booleangives the propertyisXandwithIsX. AStringgetterisLabel()givesisLabelandwithIsLabel. A primitiveisPrimitive(): booleangivesprimitive, because its prefix is dropped. -
After
getorisan upper case letter must follow:getName()andisActive()are properties, whilegetaway(),isolate()andisolated()are not. -
Getters whose names are not Java identifiers are skipped. Kotlin emits such names for mangled accessors, for example of value classes.
-
Properties are read from getter methods only. A property that has no getter, because Kotlin exposes it as a field, gets no matcher method. The repository has no test for
@JvmFieldor for@get:use-site targets, so that behaviour is not covered by this guide.
For the model data class KotlinProbe(val enabled: Boolean, val isX: Boolean?, val isLabel: String) the matcher therefore has withEnabled, withIsX and withIsLabel, as the end-to-end test HelloKotlinTest asserts.
Nullability
When the JSpecify annotations are on the classpath, the generated matchers carry @NullMarked and @Nullable on equality values and on nullable properties. Kotlin then sees the real nullable and non-null types, so withNickname(null as String?) compiles.
The processor reads the nullability of a Kotlin property from the JetBrains annotations that kotlinc writes, so a String? property is nullable, and a String property is not.
Next steps
-
IDE setup for IntelliJ IDEA and Kotlin test sources.
-
Completeness check for the rule that the DSL shares with the Java matchers.
-
Migrating from 5.x for the Kotlin users of 5.x.