Using Testcontainers with Micronaut and Kotest
Using real resources (i.e. databases) instead of mocks in your tests can be beneficial in several ways. With Testcontainers you can have ephemeral and lightweight instances that are provisioned and destroyed automatically, and you can integrate them with your favourite test framework as well. In this article I'll show you how can you make them work with Micronaut and Kotest.
Mocking or not mocking?
Mocking external dependencies (databases, cache providers, etc.) in unit tests is an acceptable approach, but when it comes to integration tests, you probably want to make sure you have a test environment that is similar to the "real" one, where your application will run (aka. production). Instead of mocking, you can use H2 for databases, which can run as an in-memory database, and it's able to emulate the common engines (MySQL, PostgreSQL, MS SQL etc.) as well. However, while H2 can be useful for simple applications, its compatibility modes have major limitations, so in the long term you are better off with a native solution.
The actual use case
In this article we will work with:
- a JVM application, based on Micronaut 3.x.x
- Kotlin
- Kotest (previously known as Kotlintest)
- Flyway
- PostgreSQL
- Gradle (7.x.x)
Therefore, I assume that you already have the following dependencies in your Micronaut project, they are set up and working properly:
io.micronaut.sql:micronaut-jdbc-hikari
io.micronaut.flyway:micronaut-flyway
io.micronaut.test:micronaut-test-kotest
io.kotest:kotest-runner-junit5-jvm
org.postgresql:postgresql
What will we achieve?
At the end of this, we'll have a project, where Kotest will:
- start a PostgreSQL instance before it runs the actual tests,
- clear and recreate the database schema between every test case,
- destroy the PostgreSQL instance after all the tests are done
Note that starting a database instance for every test run by rule of thumb can be a huge overhead in certain cases. For example, when you wouldn't like to run your entire test suite, but only a small unit test that has nothing to do with the database, Testcontainers will still start up and destroy an instance for it. However, when you have a lot of integration tests, you'll see a notable decrease in your test suite's run time with this approach.
Let's write some code!
First of all, we have to add Testcontainers as a testImplementation
dependency in our build.gradle
:
testImplementation("org.testcontainers:postgresql:1.16.2")
Then we'll need something that is able to start and stop a Testcontainers instance:
package com.example.testutils
import org.testcontainers.containers.PostgreSQLContainer
class TestDbContainer : PostgreSQLContainer<TestDbContainer>("postgres:12") {
companion object {
private lateinit var instance: TestDbContainer
fun start() {
if (!Companion::instance.isInitialized) {
instance = TestDbContainer()
instance.start() // At this point we have a running PostgreSQL instance as a Docker container
// We set the properties below, so Micronaut will use these when it starts
System.setProperty("datasources.default.url", instance.jdbcUrl)
System.setProperty("datasources.default.username", instance.username)
System.setProperty("datasources.default.password", instance.password)
}
}
fun stop() {
instance.stop()
}
}
}
The code above defines a singleton with two methods: start
and stop
. The former one spins up our Postgres instance, and sets the datasource's properties for Micronaut, while the latter one destroys the container.
We also have to wire this start/stop logic into Kotest's lifecycle. This is where the pre-generated ProjectConfig.kt
comes in handy:
// io/micronaut/test/kotest/ProjectConfig.kt
package io.micronaut.test.kotest
import com.example.testutils.TestDbContainer
import io.kotest.core.config.AbstractProjectConfig
import io.micronaut.test.extensions.kotest.MicronautKotestExtension
object ProjectConfig : AbstractProjectConfig() {
override fun listeners() = listOf(MicronautKotestExtension)
override fun extensions() = listOf(MicronautKotestExtension)
override fun beforeAll() {
TestDbContainer.start()
}
override fun afterAll() {
TestDbContainer.stop()
}
}
As you can see, the only thing we have to do is to call the two methods that we implemented in the previous step, inside Kotest's beforeAll()
and afterAll()
callbacks.
Since Flyway takes care of the initial schema migration after our Micronaut server starts, we can create a naive test like that:
@MicronautTest
class ExampleTest(private val repository: Repository) : StringSpec({
"when we insert something into our database, it should exist" {
repository.insert(value = "something") // We insert something in our database
repository.findByValue(value = "something") shouldNotBe null // And we expect that the inserted thing is actually there
}
})
Assuming that Repository
is something that operates on a database, this test will use your Testcontainers instance as its datasource, and will actually end up in INSERT INTO
and SELECT
queries against it. While the example above should work as expected, we also want to make sure that our test cases are not "leaking". We speak about a leak, when our test cases are not independent of each other, and one's state can affect others' result. To prevent this issue, we should simply "erase" and recreate our whole database after every test case.
But how should we do that? Well, we will use Flyway for sure, but injecting it into every test class, and calling it manually after every test case is something that doesn't sound right. A cleaner approach is when we extend one of Kotest's base spec style and implement the necessary teardown logic here. For a StringSpec
it would be like that:
abstract class DatabaseStringSpec(body: StringSpec.() -> Unit = {}) : StringSpec(body) {
@Inject
lateinit var flyway: Flyway // We inject the Flyway instance only here
override fun afterTest(testCase: TestCase, result: TestResult) {
// These two methods will completely erase and recreate the schema of our test DB instance
flyway.clean()
flyway.migrate()
}
}
Now we can rewrite our previous test to extend DatabaseStringSpec
, and we can add more cases to it, since they won't leak the database's state anymore:
@MicronautTest
class ExampleTest(private val repository: Repository) : DatabaseStringSpec({
"when we insert something into our database, it should exist" {
repository.insert(value = "something") // We insert something in our database
repository.findByValue(value = "something") shouldNotBe null // And we expect that the inserted thing is actually there
}
"when we don't call insert, the table should be empty" {
repository.findByValue(value = "something") shouldBe null // We expect that there is nothing in the database yet
}
})
You can find a working example of this setup in the repository of Kuvasz, which is also my latest pet project.