Next Level Integration Testing

2 March 2023

Next Level Integration Testing

I’ve already written about how to write simple Testcontainers Integrationtests for Kotlin in my previous blogpost. However, there were still some parts that I wasn’t all to happy about. This blogpost shows you how you can improve on the previous exampe and take your integrationtests to the next level by using custom Annotations such as @IntegrationTest to set up everything you need for your Testcontainers test.


This is the second part and the continuation of my previous blogpost here.

After I was finished with my first example of a testcontainers Integrationtest written in Kotlin, I was curious if there was still a more elegant way to write these test. Especially the need for an abstract class setting up the testcontainers felt a little bit clunky to me and violated the old “favor composition over inheritance” mantra. In real projects, these abstract classes tend to fill up quickly, and we are left with a lot of test classes extending from this class without needing most of it.

So I did some research and found this great Blogpost by Maciej Walkowiak where he shows how to use a custom annotation to start the testcontainers. Let’s have a look how we can apply this solution to our existing Project written in Kotlin.

Let’s look at the problem we are trying to solve first. This is the UserConsumerIntegrationtest we wrote.

class UserConsumerIntegrationTest(@Autowired userRepository: UserRepository, @Autowired userProducer: UserProducer) : AbstractIntegrationTest(userRepository, userProducer) {

It extends from the AbstractIntegrationTest class that looks like this:

@SpringBootTest
@ActiveProfiles("test")
@ContextConfiguration(initializers = [AbstractIntegrationTest.Initializer::class])
abstract class AbstractIntegrationTest @Autowired constructor(
    val userRepository: UserRepository,
    val userProducer: UserProducer,
) {

This is not too bad for our simple example project. But in reality, you will have a lot of different Integrationtests that will slightly differ from each other. So you will either need to create a different Abstractclass for all of them or just put everything into this one. Both are not optimal solution as they don’t really compose well. However, following the advice in the blogpost above, we can extract the TestcontainersInitializer into it’s seperate class like this:

class TestcontainersInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
    companion object {
        val kafkaContainer = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1"))
        val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:12"))
    }

    override fun initialize(configurableApplicationContext: ConfigurableApplicationContext) {
        kafkaContainer.start()
        postgresContainer.start()

        TestPropertyValues.of(
            "spring.kafka.bootstrap-servers=${kafkaContainer.bootstrapServers}",
            "spring.datasource.url=${postgresContainer.jdbcUrl}",
            "spring.datasource.username=${postgresContainer.username}",
            "spring.datasource.password=${postgresContainer.password}"
        ).applyTo(configurableApplicationContext.environment)
    }
}

and create a custom annotation that uses this initializer:

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ContextConfiguration(initializers = [TestcontainersInitializer::class])
annotation class EnableTestcontainers

Taking this concept further, we can abstract away all the annotations that are needed behind a single @IntegrationTest annotation. When we have a look at the now removed abstract class, we get all the annotations we needed:
“

@SpringBootTest
@ActiveProfiles("test")
@ContextConfiguration(initializers = [AbstractIntegrationTest.Initializer::class])
abstract class AbstractIntegrationTest

So we take these annotation, combine it with our new @EnableTestcontainers Annotation and compose this to a brand new @IntegrationTest Annotation like this:

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@EnableTestcontainers
@SpringBootTest
@ActiveProfiles("test")
annotation class IntegrationTest(
    vararg val properties: String = []
)

Here we have all the annotations needed for our test to run successfully. And our Integrationtest now looks like this:

@IntegrationTest
class UserConsumerIntegrationTest @Autowired constructor(
    private val userRepository: UserRepository,
    private val userProducer: UserProducer)

Beautiful! Gorgeous! Additionaly, the properties field we added to the annotation can now be used to pass custom properties through to our @SpringbootTest Annotation like this:

@IntegrationTest(properties = ["spring.profiles.active=test"])

Which allows us to customize a lot of thins using just this single annotations. The cool thing about this is that it does not need to stop there. You can now compose different annotations and create custom annotations like @MessagingIntegrationTest or @WebIntegrationTest where you can put different annotations and JUnit extensions like cleaning the database beforeeach test run. (An example that you can also find in Maciej’s Blog.)

Hope you enjoyed and can start to write beautiful integration tests now! As always, the code can be found on my Repository