Fixtures made easy with KSP
Standing on the shoulders of giants
In a previous blog post, we discussed how to build tools with the help of KSP. In this one, we will present one of the tools we built in Blueground that recently has been released as an open-source project. Initially, we were inspired by Philipp Hauer's blog post to build helper functions that instantiate objects in our tests. We love this pattern but after a while, we realized that we produced a lot of boilerplate code and did a lot of repetitive work. This was the time that we needed to go a step further and thought this was a good candidate for generating a tool to automate all this work. The name of it is
The source code of the library and how to install it can be found here. Below we are going to describe its major functionalities of it.
Generate the helper functions
Based on Philipp Hauer's blog post if we have a data class like that
data class Foo( val stringValue: String, val intValue: Int, )
it is a good pattern for our tests, to generate a similar helper function
fun createFoo( stringValue: String = "stringValue", intValue: Int = 0 ) : Foo = Foo( stringValue = stringValue, intValue = intValue )
To achieve that with the help of our library, all we have to do is to add the
@Fixture annotation and the helper function will automatically be generated!
@Fixture data class Foo( val stringValue: String, val intValue: Int, )
The naming convention for the generated function is the class' name with the
create prefix. So, from the
Foo class, the
createFoo function will be generated. This function will be placed in a file with the
FooFixture.kt name, and the file will be placed in the
Foo's package under the
build/generated/ksp/kotlin/ path. The params that we can have are all the basic types, collections, sealed classes, enums, classes related to dates, and other classes annotated with the
@Fixture annotation (for more information about the supported types you can check here).
Randomize your data
The generated functions have default values for their parameters. The values are standard and are based on the parameter's type (the standard values per supported type can be found here). Sometimes we may need to spice things up and randomize our data. To assign random default values to our helper functions we can set the next KSP option.
After applying the previous option, every time we generate the functions random default values will be assigned. Things to
note here are:
- The default behavior is to not randomize the data.
- Randomization happens in every generated function in the Gradle module. In the future, we may consider randomizing
data per fixture.
Handle not supported field types
There will be some rare cases where we have a data class that contains another class which does not belong in the supported field types. Additionally, this not supported class may be part of another library, so we could not use the
Fixture annotation on it. Let's assume that we have the following class:
data class Foo( val doubleValue: Double, val barValue: Bar )
Bar class is part of another library. To help the processor generate a
Bar class we can use the
FixuteAdapter annotation. This annotation can be applied to a top-level function, like that:
@FixtureAdapter fun barFixtureProvider(): Bar = Bar()
Then the processor will use the annotated function to generate the helper function.
fun createFoo( doubleValue: Double = 0.0, barValue: Bar = barFixtureProvider() ) : Foo = Foo( doubleValue = doubleValue, barValue = barValue )
How to overcome known issues
Source sets Issue
For now, we can not run KSP on the main source sets and generate classes in the test source sets. This seems that will not be the case after fixing this issue. Until then, we can avoid generating test functions in our release code by setting the following KSP option:
The default value of this option is false. We need to explicitly set it to
true when we are about to run our tests.
This can be easily done with a function in our Gradle file.
If our data class contains a sealed class field, and the declaration of the sealed class belongs to another module, then our processor can not recognize that this field is a sealed class. This means that it can not treat accordingly. The reason behind that is that the generated bytecode does not contain any information about being a sealed class (due to java interoperability). To overcome this issue we can use
FixtureAdapter as it was described previously.
If our data class contains a field whose class declaration is annotated with the
Fixture annotation, and this class belongs to another module, then our processor can not recognize that this field is a
Fixture. The reason is that we can not retrieve the annotations of a class defined in another module during the processing cycle. We suppose that the reason behind that is to ensure performance. To overcome this issue we have created the
ModularizedFixture annotation to help our processor do the work. All you have to do is to annotate the field with it.
@Fixture data class Foo(@ModularizedFixture val bar: Bar) @Fixture data class Bar(val someValue: String) // This class is defined in another module
In this blog post, we showcased one of the tools that we build in Blueground with the help of KSP. Hopefully, this tool can help you to generate your fixtures and also inspire you to build your own tools. Additionally, if you think that something is missing, feel free to contribute or make a feature request in our GitHub repo. We will be glad to hear your opinions! 😄