July 9, 2024

Testcontainers in Spring Boot

I had known about testcontainers for a while, but I only learned to really appreciate them after I saw how a colleague integrated them into one of our new services. Since then, I advocate to use them for all new services, and to update the existing ones which still use a manual setup.

We use postgres as our default database backend. To test our database integration, we bring up a postgres container with a fresh database and run tests against this. Similarly, we use a localstack container to test our S3 and SQS integrations. Before testcontainers, to run the tests, we would need to manually start a postgres container and a localstack container. I find this manual process a bit annoying and would frequently forget to do it, so the first test run would fail. It also has to be documented somewhere, so that new developers know what to do. Also, the containers need to be accessible on fixed ports so that the tests can access them. This can lead to issues when other services or containers want to use those ports.

Testcontainers take care of all of this, and they are fairly straightforward to set up. In the case of postgres (or similar databases), I would argue that the testcontainer setup is even simpler than the manual setup.

Testcontainers for postgres

I think the JDBC support for testcontainers is really genius. All we need is to ensure that the postgres testcontainer module is on the classpath. For example, in Gradle we can do this by adding the dependency

testImplementation("org.testcontainers:postgresql:1.19.8")

To create a database, we use a JDBC connect string like jdbc:tc:postgresql:16:///databasename. When our application first tries to connect to this database, the testcontainer module will automatically start a new postgres container with a fresh database, using postgres version 16. It automatically takes care of username and password, so we don’t have to specify them anywhere (but if you need them, for example, because you want to connect to it directly: both username and password are test).

This means that if we are using the Spring profile test for our tests, all we need to do in order to use a postgres testcontainer is to specify

spring.datasource.url: jdbc:tc:postgresql:16:///databasename

in our application-test.yaml. (The 16 is the version of postgres that is going to be used. Note that databasename is just a placeholder to make this a valid JDBC string; the actual database will be called test). This one line allows to get rid of the manual setup, the necessary documentation, and the potential port conflicts! Whenever we run a @SpringBootTest, @DataJpaTest, or @JooqTest, the testcontainer setup will ensure that the appropriate container is brought up, and it will be removed again when the JVM shuts down.

Testcontainers for localstack

For localstack, the integration is not as slick as for JDBC, but I think it is still worthwhile to use over the manual approach. As before, we first have to add the package to the class path.

testImplementation("org.testcontainers:localstack:1.19.8")

With the Kotlin AWS SDK, the production configuration for S3 could define a bean like

@Configuration
class AWSConfiguration {

    @Bean
    fun s3Client(): S3Client = runBlocking { S3Client.fromEnvironment() }
}

To use testcontainers, we create a separate test configuration

@TestConfiguration
class AWSTestConfiguration {

    @Bean
    fun localstack(): LocalStackContainer = LocalStackContainer(
        DockerImageName.parse("localstack/localstack:3.2.0")
    )
        .withServices(Service.S3)
        .also { it.start() }

    @Bean
    fun s3Client(localstack: LocalStackContainer): S3Client = S3Client {
        endpointUrl = Url.parse(
            localstack.getEndpointOverride(Service.S3).toString()
        )
        credentialsProvider = StaticCredentialsProvider {
            accessKeyId = localstack.accessKey
            secretAccessKey = localstack.secretKey
        }
        region = localstack.region
    }
}

Defining the localstack container as a separate bean (as opposed to defining it inline in s3Client) has the advantage that, if we also need to test different AWS services like SQS, we only need to bring up one container for all services.

As with the postgres example, the containers will be brought automatically for tests requiring it, and they will be removed when the JVM shuts down.

—Written by Sebastian Jambor. Follow me on Mastodon @crepels@mastodon.social for updates on new blog posts.