Fixtures made easy with KSP

Fixtures made easy with KSP
Fixtures are one of many excellent use cases for the powerhouse that is called 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 Fixtures 🎉

Fixtures

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.

ksp.arg("fixtures.randomize", "true")

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
)

and the 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:

ksp.arg("fixtures.run", "false")

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.

Multi-module support

Sealed classes

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.

Fixtures

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

Recap

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! 😄