Catadioptre for Kotlin
Table of contents
- Import the dependencies
- Generate extension functions to access your private members in tests
- Setting a private or protected property
- Getting a private or protected property
- Executing a private or protected function
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
- Suspend functions are not supported, because the annotation processor cannot see them as annotated functions.
- 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.