A couple of months ago I published an article, where I showed how to bind Micronaut, Testcontainers and JUnit together. Recently, a new version of Micronaut arrived (3.5) that added the support for Kotest 5 framework. It targets Kotlin language. This is a great opportunity to get back to the topic and try out another language. We will see how to leverage Kotest to build an efficient test startup for your integration tests. We’ll also discover where we still have some catches to watch out. The article comes with a complete, working example to study.

tl;dr;

Contrary to JUnit Jupiter 5, Kotest has a built-in support for running project-wide actions. It helps starting Testcontainers. However, if you integrate it with micronaut-test, beware of the default way of injecting test properties which has some limitations!

What we will learn

In this article, we will learn, how to:

  • create Kotest project configurations and listeners
  • inject dynamic configuration into Micronaut apps
  • use Java Service Locator
  • speed up your integration tests through the combination of the above

Example application

To demonstrate how to build an integration test environment for Kotest and Micronaut, we are going to write a sample, minimal application with four REST endpoints. They allow managing products and articles stored in a MongoDB database. There are also two tests specs that verify each resource. To set up a MongoDB instance, we are going to use Testcontainers library. Note that Kotest 5 brings many small, but important API changes over version “4”. You need at least Micronaut 3.5 to use it with this version of Kotest. In the interest of space, the article shows only the code relevant to the testing environment, but you can download a complete example from Github.

Sample code

Find a complete example on Github: zone84-examples/efficient-test-startup-kotest

Overview of Kotest

Kotest is a testing framework that targets Kotlin language. It is built on top of JUnit Platform, which provides a nice integration with IDE-s. The same platform is also used by JUnit 5, Spock and a couple of other frameworks. This makes it possible to mix them in one project.

Kotest offers a more function-like approach for writing tests inspired by JavaScript frameworks. It comes with its own (optional) assertion library and extensions for several testing tools (Wiremock, Testcontainers, etc.). A notable feature is a simple, yet powerful extension model. By writing extensions, we can automate management tasks or setting up the test environment.

Test lifecycle hooks in JUnit 5 and Kotest

If you read the previous Efficient test startup article, you might remember that JUnit 5 offers @BeforeAll / @AfterAll hooks. They help creating actions invoked before and after a group of tests. Unfortunately, they are restricted to a single test class file. In JUnit 5, there is no way to install a project-wide hook. It makes e.g. starting Docker containers once, before all tests, a small challenge. To deal with that, we had to write a low-level extension to the underlying JUnit Platform.

In Kotest, the situation is different. The framework provides a nice way to write and install project-wide extensions. Such an extension can run before all tests in our test suite. The downside is that it supports only Kotest. In some cases, you might need to mix two frameworks in a single suite, e.g. during the migration from one to another. In this case, it might not work for tests written in another framework.

Efficient test startup solution for Kotest

Let’s start building the efficient test startup with Kotest framework. Firstly, we need to add some libraries to our project. The proper versions will be selected by Micronaut and an additional Gradle Version Catalog definition inside our project.

testImplementation("io.micronaut.test:micronaut-test-kotest5")
// from version catalog: io.kotest:kotest-runner-junit5-jvm
testImplementation(libs.test.kotest.runner) 
testImplementation("org.testcontainers:mongodb")
testImplementation("org.testcontainers:testcontainers")

Environment class

Now let’s create an Environment class. This is my own pattern to collect the test startup code in a single place. Its purpose is managing the startup of MongoDB, decoupled from the framework.

object Environment {
    const val DATABASE_NAME = "mydb"

    private val log = KotlinLogging.logger { }
    private val mongoDb = MongoDBContainer(DockerImageName.parse("mongo:5.0.5"))
    private lateinit var client: MongoClient

    fun getMongoDbPort(): Int {
        return mongoDb.firstMappedPort
    }

    fun startServices() {
        mongoDb.start()
        client = MongoClients.create(mongoDb.replicaSetUrl)
        log.info {
            "Started MongoDB service on mapped port ${getMongoDbPort()}, "
                + "replica set: '${mongoDb.replicaSetUrl}'"
        }
    }

    fun cleanUp() {
        log.info { "Dropping MongoDB collections before test..." }
        val database = client.getDatabase(DATABASE_NAME)
        database.listCollectionNames()
            .forEach {
                database.getCollection(it).drop()
            }
    }
}

To start the Docker container, we use Testcontainers library. It reduces the whole task just to creating an instance of MongoDBContainer, and calling start(). The library has a useful, but tricky feature called “port randomization”. Every time we launch tests, MongoDB starts on a different, random port. It helps isolating test launches in the build system. However, we also need to pass the generated port number to the tested application. For this, we expose getMongoDbPort() method to read this port number later.

The cleanUp() function drops all the collections between tests. In this way, the state of test A does not leak out to test B. In most cases, this operation is safe for MongoDB. The driver will simply re-create any missing collections every time the code refers to them. The only use cases to watch out are:

  • some predefined state installed at application startup,
  • background-running cyclical tasks that need the database.

Here, this solution might wipe out some critical data and cause random fails. You might need to add some exclusion lists then to prevent deleting certain collections.

In short…

You can isolate the tests by deleting all MongoDB collections between them. In most cases, this is safe, but beware of some corner cases!

ProjectConfig and Kotest listeners

Now we are ready to integrate Environment class with Kotest. To create a project-wide configuration in this framework, we just need to create a ProjectConfig object that extends AbstractProjectConfig. Then, we can use it to install extensions, and apply global configuration:

object ProjectConfig : AbstractProjectConfig() {
    override val isolationMode = IsolationMode.InstancePerTest

    override fun extensions() = listOf<Extension>(
        ProjectListener,
        MicronautKotest5Extension
    )
}

By default, Kotest retains the same spec class instance between tests. This is a different behavior than the one we know from JUnit. To make sure that we get a fresh spec object for every test, we need to switch the isolation mode to InstancePerTest. Then, we can install some extensions. One of them is for running Micronaut app. The second one, ProjectListener is our own code:

object ProjectListener : BeforeProjectListener, BeforeTestListener {
    override suspend fun beforeProject() {
        Environment.startServices()
    }

    override suspend fun beforeTest(testCase: TestCase) {
        Environment.cleanUp()
    }
} 

As we can see, creating an extension is just about selecting proper interfaces for the hooks we want to use, and then registering our extension. The hooks simply call the functions from Environment class. BeforeProjectListener guarantees that we’ll run Docker container exactly once, and that they will be re-used across all tests.

Injecting configuration into Micronaut

The last thing is applying a runtime modification to the Micronaut configuration. Because we use a random MongoDB port for every test run, we need to pass this port to the framework. Micronaut-test project already contains a solution for that called TestPropertyProvider. Unfortunately, it has two limitations:

  • it can be used only on individual spec classes which must use a no-arg constructor (no constructor injection!)
  • it works only with SingleInstance isolation mode

We want to use a different isolation mode, therefore we need to find other way. Fortunately, there is one: we can write our own PropertySourceLoader for Micronaut. This is a standard way to load the properties from custom sources:

class EnvironmentStartupListener : PropertySourceLoader {
    override fun load(resourceName: String, resourceLoader: ResourceLoader?): Optional<PropertySource> {
        return if (resourceName == "application") {
            Optional.of(
                PropertySource.of(
                    mapOf(
                        "mongodb.uri" to "mongodb://localhost:" +
                             Environment.getMongoDbPort()
                    )
                )
            )
        } else Optional.empty()
    }

    override fun loadEnv(
        resourceName: String?,
        resourceLoader: ResourceLoader?,
        activeEnvironment: ActiveEnvironment?
    ): Optional<PropertySource> {
        return Optional.empty()
    }

    @Throws(IOException::class)
    override fun read(name: String?, input: InputStream?): Map<String?, Any?>? {
        return null
    }
}

Every PropertySourceLoader must return a plain map of property names and their values. However, we cannot register it as a bean. It may be surprising, but there is a good reason for that. Micronaut creates all beans only when the entire configuration is already loaded. On the other hand, the loaders must do their job earlier to build the configuration. So how we can register one? We might use a Java feature known as Service Locator.

Using Service Locator to install a property source loader

Service Locator is a built-in Java feature for building simple plugin architectures. Basically, it allows us finding all implementations of a certain interface on the classpath/modulepath and return their instances. It is used internally by many parts of Java Runtime Library. Many frameworks and libraries use it, too, to auto-detect plugins. The only catch is that Service Locator does not provide any dependency injection mechanism. For this reason, it is mostly used for low-level stuff. For detecting plugins, Service Locator uses simple text files located in project resources. Starting from Java 9, we can also use module descriptors for that purpose.

To register our listener, we need to create a text file in /src/test/resources/META-INF/services directory. The file must be named from the interface we want to extend – io.micronaut.context.env.PropertySourceLoader. It must contain just one line: the name of the implementation class.

tech.zone84.examples.efficientteststartup.environment.EnvironmentStartupListener

And basically… that’s all. Micronaut should now properly detect our custom property source that overwrites MongoDB URI.

In short…

Service Locator provides a way for auto-detecting plugins across the entire JVM. Starting from Java 9, you can also register your implementations using module descriptors in module-info.java files.

Summary

Kotest extensions make the integration with Micronaut and Testcontainers really easy. Our test startup in Kotest helps reducing the execution time by starting Docker containers only once. However, we can see that there’s still a catch with port randomization. Once we plumb everything together, we can start building test utilities for our domain, and writing the actual tests.

Sample code

Find a complete example on Github: zone84-examples/efficient-test-startup-kotest

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments