Skip to the content.

Catadioptre for Kotlin

Table of contents

Import the dependencies

You can directly get the dependency from Maven Central.

With Gradle and the Groovy DSL

testImplementation 'io.aeris-consulting:catadioptre-kotlin:0.4.1'

// For the code generation.
compileOnly 'io.aeris-consulting:catadioptre-annotations:0.4.1'
kapt 'io.aeris-consulting:catadioptre-annotations:0.4.1'

With Gradle and the Kotlin DSL

testImplementation("io.aeris-consulting:catadioptre-kotlin:0.4.1")

// For the code generation.
compileOnly("io.aeris-consulting:catadioptre-annotations:0.4.1")
kapt("io.aeris-consulting:catadioptre-annotations:0.4.1")

With Maven

<dependency>
  <groupId>io.aeris-consulting</groupId>
  <artifactId>catadioptre-kotlin</artifactId>
  <version>0.4.1</version>
  <scope>test</scope>
</dependency>

<!-- For the code generation. -->
<dependency>
  <groupId>io.aeris-consulting</groupId>
  <artifactId>catadioptre-annotations</artifactId>
  <version>0.4.1</version>
  <scope>provided</scope>
</dependency>
  [...]
<execution>
  <id>kapt</id>
  <goals>
    <goal>kapt</goal>
  </goals>
  <configuration>
    <sourceDirs>
      <sourceDir>src/main/kotlin</sourceDir>
      <sourceDir>src/main/java</sourceDir>
    </sourceDirs>
    <annotationProcessorPaths>
      <annotationProcessorPath>
        <groupId>io.aeris-consulting</groupId>
        <artifactId>catadioptre-annotations</artifactId>
        <version>0.4.1</version>
      </annotationProcessorPath>
    </annotationProcessorPaths>
  </configuration>
</execution>

You can find more on Maven Central.

Generate extension functions to access your private members in tests

Configure the build

To facilitate the access to the private members in a test context, Catadioptre generates for you extended functions, that route the calls to the private members using reflection.

Whereas those extensions are meant to be only used in a testing context, you can use them for production by adapting the configuration documented below.

Those extensions are generated by an annotation processor and require Kapt to be configured.
See here how to add the required dependency.

To include the generated functions into the test sources, configure your project as follows:

With Gradle and the Groovy DSL

kotlin.sourceSets["test"].kotlin.srcDir(layout.buildDirectory.dir("generated/source/kaptKotlin/catadioptre"))
kapt.useBuildCache = false

With Gradle and the Kotlin DSL

kotlin.sourceSets["test"].kotlin.srcDir(layout.buildDirectory.dir("generated/source/kaptKotlin/catadioptre"))
kapt.useBuildCache = false

With Maven


<build>
  <plugins>
    <plugin>
      <groupId>org.codehaus.mojo</groupId>
      <artifactId>build-helper-maven-plugin</artifactId>
      <version>3.2.0</version>
      <executions>
        <execution>
          <id>add-test-source</id>
          <phase>generate-test-sources</phase>
          <goals>
            <goal>add-test-source</goal>
          </goals>
          <configuration>
            <sources>
              <source>target/generated-sources/kaptKotlin/catadioptre</source>
            </sources>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Annotate the code

Then, simply add the @KTestable annotation on the private members and compile the class:

class CatadioptreExample {

    @KTestable
    private var configuration: Map<String, Double>? = mutableMapOf("any" to 1.0)

    @KTestable
    private fun multiplySum(multiplier: Double = 1.0, vararg valuesToSum: Double?): Double {
        return valuesToSum.filterNotNull().sum() * multiplier
    }

}

Finally, use the generated extended functions on your tests:

val instance = CatadioptreExample()

// Read an annotated property.
val result = instance.configuration() // result is a Map<String, Double>?
// Write an annotated property.
instance.configuration(mapOf("other" to 2.0))
// Clear an annotated property.
instance.clearConfiguration()

// Execute an annotated function.
val result = instance.multiplySum(2.0, arrayOf(1.0, 3.0, 6.0))

Limitations on the generation of extended functions for Kotlin

  1. Suspend functions are not supported, because the annotation processor cannot see them as annotated functions.
  2. Optional parameter are required in the generated functions.

To bypass those limitations - use suspend functions or verify the behavior or a function with a default parameter value - you will have to use the utils provided below.

Further examples

This repository contains three different folders to demo the full configuration and usage of Catadioptre, using Gradle ( with Groovy or Kotlin DSL) and Maven. You can run this examples locally to see how the whole is working:

Setting a private or protected property

Setting a value into a property can be performed with the extension function setProperty on any instance or object. The function takes the name of the property as first argument and the value to set as second.

 instance.setProperty("myProperty", 456)
    .setProperty("myOtherProperty", true)

Note that you can chain the calls for a fluent coding.

You can also use the infix function withProperty

 instance withProperty "myProperty" being 456

While you can use setProperty or withProperty to set a property to null, a more concise option consists in using the function clearProperty:

instance clearProperty "myProperty"

Getting a private or protected property

To get the current value of a property, you can use the function getProperty providing the property name as first argument.

val value: Int = instance getProperty "myProperty"

The above example shows how to proceed with the infix approach, an alternative is the following:

val value = instance.getProperty<Int>("myProperty")

Executing a private or protected function

Functions without parameter

The simplest way to execute a niladic function is to use invokeNoArgs providing the function name as argument:

val value: Int = instance invokeNoArgs "calculateRandomInteger"

The equivalent function to invoke a suspend function without parameter is coInvokeNoArgs.

val value: Int = instance coInvokeNoArgs "suspendedCalculateRandomInteger"

Functions with one or more parameters

The functions invokeInvisible (and respectively coInvokeInvisible for the suspend functions) executes the function with the name passed as first argument, using the parameters provided in the same order.

The following example executes the function divide passing it 12.0 and 6.0 as arguments.

val result: Double = instance.invokeInvisible("divide", 12.0, 6.0)

The value of result is 2.0.

Given the richness of the functions declarations in Kotlin - optional parameters, varargs, it is not trivial to resolve the real function to execute when several ones have the same name.

To help in this resolution, Catadioptre requires information about the types or names of the null, omitted optional or variable arguments.

We provide convenient arguments wrappers to achieve this in a concise way.

Passing a null argument

To simply pass a null value as an argument while providing the type of the argument, you can use the wrapper nullOf.

val value: Double = instance.invokeInvisible("divideIfNotNull", nullOf<Double>(), named("divider", 6.0))

Naming an argument

Wrap the argument with the function named, giving first the name, then the value. To execute the function

private fun divide(value: Double, divider: Double): Double = value / divider

You can use:

val value: Double = instance.invokeInvisible("divide", named("value", 12.0), named("divider", 6.0))

When using named arguments, their order in the call no longer matters.

If the value is null, simply use namedNull<Double>("value"). The type of the argument is required to match the function in case of method overloading.

val value: Double? = instance.invokeInvisible("divideIfNotNull", namedNull<Double>("value"), named("divider", 6.0))

Passing a variable argument

To provide all the values of a variable argument, you have to use vararg:

val result: Double = instance.invokeInvisible("divideTheSum", 2.0, vararg(1.0, 3.0, 6.0))

This will execute the following function summing 1.0, 3.0 and 6.0 (= 10.0) and dividing the sum by 2.0:

private fun divideSum(divider: Double = 1.0, vararg values: Double?): Double {
    return values.filterNotNull().sum() / divider
}

Omitting an optional argument to use the default value

When you want to execute a function that has a parameter with a default value you want to apply, use omitted

This function as a default divider set to 1.0.

val result: Double = instance.invokeInvisible("divideTheSum", omitted<Double>(), vararg(1.0, 3.0, 6.0))

This will execute the following function summing 1.0, 3.0 and 6.0 (= 10.0) and dividing the sum by 1.0, the default value of the parameter divider:

private fun divideSum(divider: Double = 1.0, vararg values: Double?): Double {
    return values.filterNotNull().sum() / divider
}

Combining wrappers

Last but not least, you can also combine the wrappers to create named variable arguments, or named omitted.

val result: Double = instance.invokeInvisible("divideTheSum", named("divider", omitted<Double>()), named("values", vararg(1.0, 3.0, 6.0)))

While this is in most cases unnecessary, this might help in resolving to the adequate function to execute when functions of a class are too similar.