Recently, I wanted to rewrite a JUnit integration test for my Spring Boot application. Since it was an integration test, I used Testcontainers to start a database. While there are Kotest extensions for Spring and Testcontainers we have the problem that both do not cooperate.
The Testcontainers extension does not integrate with Spring in a way that ensures the container starts early enough for Spring and overrides the Spring properties (e.g., spring.data.jdbc.url
).
While searching for a solution, I came across several articles that seemed somewhat outdated. Therefore, I wanted to share my solution here. The articles I found were:
Bootstrap project with Spring Boot, Kotest, Testcontainers & MongoDB | Jakub Prądzyński's Blog
TestContainers Extention and Spring boot startup timing · Issue #1649 · kotest/kotest
What all of those solutions have in common is that they wrap the container and then control its lifecycle. This could be achieved through extension functions, static methods, or an abstract test superclass. Having all the lifecycle callbacks, you can then start and stop the container before the Spring application context starts and override the properties.
Although these approaches work, they seemed unnecessarily complex to me.
Starting point
What I started with was a common Spring JUnit test with Testcontainers:
@ActiveProfiles("default", "test")
@Testcontainers
@SpringBootTest
class SomeIntegrationTest() {
companion object {
@Container
private val postgres = PostgreSQLContainer("postgres:16.1")
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
with(registry) {
add("spring.flyway.url") { postgres.jdbcUrl }
add("spring.flyway.user") { postgres.username }
add("spring.flyway.password") { postgres.password }
add("spring.r2dbc.url") {
postgres.jdbcUrl.replaceFirst("jdbc", "r2dbc")
}
add("spring.r2dbc.username") { postgres.username }
add("spring.r2dbc.password") { postgres.password }
}
}
}
// setup, teardown and tests go here
...
}
While I could keep the @SpringBootTest
annotation due to the Kotest extension @Testcontainers
, which helps with the container lifecycle, does not work with Kotest.
The Kotest version
What my predecessors did not have at their time was the @ServiceConnection
annotation. You can read more about it here. Given a standard Postgres container, it should be possible to use this annotation. So, I experimented and rewrote the test like this:
import io.kotest.extensions.testcontainers.perSpec
@ActiveProfiles("default", "test")
@SpringBootTest
class SomeIntegrationTest : StringSpec() {
companion object {
@ServiceConnection
private val postgres = PostgreSQLContainer("postgres:16.1")
.apply { this.start() }
}
init {
listener(postgres.perSpec())
// setup, teardown and tests go here
...
}
}
I also encountered the issue where the container started too late for Spring. To address this, I start the container explicitly in the companion object. Registering the container as a listener using perSpec()
then managed the rest of the lifecycle.
With spring-boot-testcontainers
in the classpath, there’s no need to override properties manually anymore. We can just add the @ServiceConnection
annotation.
Conclusion
So, all in all, instead of having to write our own lifecycle wrapper we just use perSpec()
and the manual start with .apply { this.start() }
and have a nicely working integration test. The @ServiceConnection
annotation could also have been applied to the original test, but it’s great to see that it works seamlessly with the Kotest Spring extension.