Tremendous Simplification of SpringBoot Development with TestContainers
A couple of weeks ago, Spring Boot 3.1.0 has been released, and it contains some tasty features. In this article, we’ll focus on the brand-new built-in integration with TestContainers. This feature brings great flexibility to developers running Spring Boot Apps on localhost.
In the first part, we’ll build a heavily-integrated SpringBoot App that uses Kafka, Postgres, Redis, and a simple Flask web app. Nothing fancy. In the second part , we’ll build the infrastructure in Java to run the application and all the linked services with a simple Gradle command. Finally, we will see some additional appetizing features and configs.
As always, all code is available in this public GitHub repository.
Photo by Nano Do on Unsplash
The Spring Boot App
– Postgres –
Our application will perform simple CRUD operation reading and persisting data from a Postgres Database. First things first, let’s see the required dependencies:
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'org.postgresql:postgresql'
Secondly, we need to specify the initialization mode in the application.properties file:
spring.sql.init.mode=always
And some init scripts to put in the resource folder.
The schema.sql file:
The data.sql file:
Finally, we need the Entity and the Repository classes:
Remember to specify the package where Spring Boot can find the Entities using the right annotation:
– Kafka –
The integration with Kafka is simple and straightforward. This is the only dependency we need:
implementation 'org.springframework.kafka:spring-kafka'
Let’s define the Consumer and Producer properties in the application.properties file:
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
spring.kafka.consumer.group-id=tc
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.properties.spring.json.trusted.packages=gae.piaz.*
And of course, we need a Producer and a Consumer for our “customers” topic. In this case, we have both producer and consumer in the same service, for simple testing purposes:
– Redis –
In this case, we want to use using Redis as a caching layer. So we just need to add these two spring-boot-starter dependencies to the build.gradle:
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Define some magic Java config:
Enable caching with the right annotation:
And use the @Cacheable annotation where we need it. In this case, we’ll cache a CustomerDTO object:
– Flask Application –
The integration with the Flask application from the point of view of the Spring Boot App is nothing else than a simple Rest call:
Reading host and port from the application.properties file:
external.server.host=${EXTERNAL_CUSTOMER_SERVICE_HOST:http://localhost:}
external.server.port=${EXTERNAL_CUSTOMER_SERVICE_PORT:8080}
We will see why we are using System Variables in the “additional-config” part of the article. If you are guessing that the variable will be set at runtime to hit a TestContainer, you are right!
Local Development with TestContainers
Adding new features or fixing some bugs with this highly integrated Application might be difficult for all the services that we need to mock and exclude, or for all the configurations that we need to tune to connect to remote hosts.
With TestContainers built-in integration provided in SpringBoot 3.1, starting our Application with all 4 containers needed it’s super simple, and we don’t have to change any configuration to make it work like a charm.
First of all we need to import the right dependencies:
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:kafka'
testImplementation 'org.testcontainers:postgresql'
Now we can define a new TestApplication Class. The name of the class should be placed in the same package as the Main Application (but in the Test folder) and should be named Test-NameOfApplication:
We can run this application by “executing the main” through any IDE, or we can run the gradle command “bootTestRun”.
Let’s focus on the tasty part now. Let’s define our Containers using ONLY Java code.
First of all, we need to use additional Container classes to avoid the “Raw use of parameterized class ‘GenericContainer’“ error. This is because Test-Container uses a self-typing mechanism to make fluent method works with extended classes: https://stackoverflow.com/a/57077189. It’s not mandatory, and probably we will not need this in future versions of TestContainers, but we want to keep our IDE without warnings (that yellow underline can hurt eyesight):
We don’t need a custom KafkaContainer class because it’s the only provided container that is using generics correctly.
Now we can define Container Bean classes handled entirely by the Spring Context (container start and stop included):
@ServiceConnection is the brand-new annotation that will create all applicable connection details beans for a given Container. For example, a PostgreSQLContainer will create both JdbcConnectionDetails and R2dbcConnectionDetails. In this case, we just need the JDBC connection. We don’t need to configure any host, port, password or any proxy!
It’s a good practice to also provide custom names to containers, and thanks to the CMD modifier we can also set a memory limit. As you might imagine, this is useful if we need to start dozens of TestContainers on the same host. Additionally, this container will be considered as ready when the LogMessage specified in the “waitingFor” method pops up.
Finally, the .withReuse(true) method is telling Spring to not recreate this container if it’s already running. Being an experimental feature, we can enable reusability by adding testcontainers.reuse.enable=true in a ~/.testcontainers.properties file. As an alternative to this, with Spring DevTools and the @RestartScope annotation, it’s possible to avoid completely the recreation of this bean if the application restarts. I prefer relying on the “reusability” of containers.
The config for the Redis TestContainer is pretty similar:
The main difference is that we need to specify the “name” of the service connection: the Auto-Config in this case works only if the Container is named “redis” and Redis is not providing a TestContainer library yet. That’s why we also need to expose port 6379 manually.
Now for the Flask App and the PythonContainer:
In this case, we’ve built an image from a Dockerfile that is located in the test/resources folder. This is all the code we need to start our TestApplication. We just need to run ”./gradlew bootTestRun” and after some time we will see all docker containers started and ready, and the application ready to serve web requests.
Awesome!
Additional Configurations
Now for some additional sparkling configurations. In case of errors in an external Container, it might be difficult to reach and read the logs. TestContainers to the rescue!
With a simple configuration, we can see the logs of Kafka in the same output stream, even decorated with a prefix:
Regarding the flask application, we are missing something.
Port 5000 is reachable only from the same network, and this should be good if the Spring Boot App was containerized as well. This is not the case. From the host’s perspective, TestContainer exposes .exposePort() on a random free port. While it’s possible to define a fixed port, this is made on purpose, to avoid port collisions.
What we can do is read the random port exposed by the container at runtime, and then set the appropriate system environment variable:
Conclusions
In this article, we have played a little bit with TestContainers in a heavily integrated SpringBoot app. We have seen the most important features and some additional configurations that could make life easier for developers trying to run applications on localhost.
As always, all code is available in this public GitHub repository.
Thank you for reading!