Spring Boot Development Mode with Testcontainers and Docker
In this article, you will learn how to use Spring Boot built-in support for Testcontainers and Docker Compose to run external services in development mode. Spring Boot introduces those features in the current latest version 3.1. Of course, you can already take advantage of Testcontainers in your Spring Boot app tests. However, the ability to run external databases, message brokers, or other external services on app startup was something I was waiting for. Especially, since the competitive framework, Quarkus, already provides a similar feature called Dev Services, which is very useful during my development. Also, we should not forget about another exciting feature – integration with Docker Compose. Let’s begin.
If you are looking for more articles related to Spring Boot 3 you can refer to the following one, about microservices with Spring Cloud.
Source Code
If you would like to try it by yourself, you may always take a look at my source code. Since I’m using Testcontainers often, you can find examples in my several repositories. Here’s a list of repositories we will use today:
- https://github.com/piomin/sample-spring-boot-on-kubernetes.git
- https://github.com/piomin/sample-spring-microservices-advanced.git
- https://github.com/piomin/sample-spring-kafka-microservices.git
You can clone them and then follow my instruction to see how to leverage Spring Boot built-in support for Testcontainers and Docker Compose in development mode.
Use Testcontainers in Tests
Let’s start with the standard usage example. The first repository has a single Spring Boot app that connects to the Mongo database. In order to build automated tests we have to include the following Maven dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Now, we can create the tests. We need to annotate our test class with @Testcontainers
. Then, we have to declare the MongoDBContainer
bean. Before Spring Boot 3.1, we would have to use DynamicPropertyRegistry
to set the Mongo address automatically generated by Testcontainers.
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PersonControllerTest {
@Container
static MongoDBContainer mongodb =
new MongoDBContainer("mongo:5.0");
@DynamicPropertySource
static void registerMongoProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", mongodb::getReplicaSetUrl);
}
// ... test methods
}
Fortunately, beginning from Spring Boot 3.1 we can simplify that notation with @ServiceConnection
annotation. Here’s the full test implementation with the latest approach. It verifies some REST endpoints exposed by the app.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PersonControllerTest {
private static String id;
@Container
@ServiceConnection
static MongoDBContainer mongodb = new MongoDBContainer("mongo:5.0");
@Autowired
TestRestTemplate restTemplate;
@Test
@Order(1)
void add() {
Person p = new Person(null, "Test", "Test", 100, Gender.FEMALE);
Person personAdded = restTemplate
.postForObject("/persons", p, Person.class);
assertNotNull(personAdded);
assertNotNull(personAdded.getId());
assertEquals(p.getLastName(), personAdded.getLastName());
id = personAdded.getId();
}
@Test
@Order(2)
void findById() {
Person person = restTemplate
.getForObject("/persons/{id}", Person.class, id);
assertNotNull(person);
assertNotNull(person.getId());
assertEquals(id, person.getId());
}
@Test
@Order(2)
void findAll() {
Person[] persons = restTemplate
.getForObject("/persons", Person[].class);
assertEquals(6, persons.length);
}
}
Now, we can build the project with the standard Maven command. Then Testcontainers will automatically start the Mongo database before the test. Of course, we need to have Docker running on our machine.
$ mvn clean package
Tests run fine. But what will happen if we would like to run our app locally for development? We can do it by running the app main class directly from IDE or with the mvn spring-boot:run
Maven command. Here’s our main class:
@SpringBootApplication
@EnableMongoRepositories
public class SpringBootOnKubernetesApp implements ApplicationListener<ApplicationReadyEvent> {
public static void main(String[] args) {
SpringApplication.run(SpringBootOnKubernetesApp.class, args);
}
@Autowired
PersonRepository repository;
@Override
public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) {
if (repository.count() == 0) {
repository.save(new Person(null, "XXX", "FFF", 20, Gender.MALE));
repository.save(new Person(null, "AAA", "EEE", 30, Gender.MALE));
repository.save(new Person(null, "ZZZ", "DDD", 40, Gender.FEMALE));
repository.save(new Person(null, "BBB", "CCC", 50, Gender.MALE));
repository.save(new Person(null, "YYY", "JJJ", 60, Gender.FEMALE));
}
}
}
Of course, unless we start the Mongo database our app won’t be able to connect it. If we use Docker, we first need to execute the docker run
command that runs MongoDB and exposes it on the local port.
Use Testcontainers in Development Mode with Spring Boot
Fortunately, with Spring Boot 3.1 we can simplify that process. We don’t have to Mongo before starting the app. What we need to do – is to enable development mode with Testcontainers. Firstly, we should include the following Maven dependency in the test
scope:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
Then we need to prepare the @TestConfiguration
class with the definition of containers we want to start together with the app. For me, it is just a single MongoDB container as shown below:
@TestConfiguration
public class MongoDBContainerDevMode {
@Bean
@ServiceConnection
MongoDBContainer mongoDBContainer() {
return new MongoDBContainer("mongo:5.0");
}
}
After that, we have to “override” the Spring Boot main class. It should have the same name as the main class with the Test
suffix. Then we pass the current main method inside the SpringApplication.from(...)
method. We also need to set @TestConfiguration
class using the with(...)
method.
public class SpringBootOnKubernetesAppTest {
public static void main(String[] args) {
SpringApplication.from(SpringBootOnKubernetesApp::main)
.with(MongoDBContainerDevMode.class)
.run(args);
}
}
Finally, we can start our “test” main class directly from the IDE or we can just execute the following Maven command:
$ mvn spring-boot:test-run
Once the app starts you will see that the Mongo container is up and running and connection to it is established.
Since we are in dev mode we will also include the Spring Devtools module to automatically restart the app after the source code change.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
Let’s what happened. Once we provide a change in the source code Spring Devtools will restart the app and the Mongo container. You can verify it in the app logs and also on the list of running Docker containers. As you see the Testcontainer ryuk
has been initially started a minute ago, while Mongo was restarted after the app restarted 9 seconds ago.
In order to prevent restarting the container on app restart with Devtools we need to annotate the MongoDBContainer
bean with @RestartScope
.
@TestConfiguration
public class MongoDBContainerDevMode {
@Bean
@ServiceConnection
@RestartScope
MongoDBContainer mongoDBContainer() {
return new MongoDBContainer("mongo:5.0");
}
}
Now, Devtools just restart the app without restarting the container.
Sharing Container across Multiple Apps
In the previous example, we have a single app that connects to the database on a single container. Now, we will switch to the repository with some microservices that communicates with each other via the Kafka broker. Let’s say I want to develop and test all three apps simultaneously. Of course, our services need to have the following Maven dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<version>1.18.1</version>
<scope>test</scope>
</dependency>
Then we need to do a very similar thing as before – declare the @TestConfiguration
bean with a list of required containers. However, this time we need to make our Kafka container reusable between several apps. In order to do that, we will invoke the withReuse(true)
on the KafkaContainer
. By the way, it is also possible to use Kafka Raft mode instead of Zookeeper.
@TestConfiguration
public class KafkaContainerDevMode {
@Bean
@ServiceConnection
public KafkaContainer kafka() {
return new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"))
.withKraft()
.withReuse(true);
}
}
The same as before we have to create a “test” main class that uses the @TestConfiguration
bean. We will do the same thing for two other apps inside the repository: payment-service
and stock-service
.
public class OrderAppTest {
public static void main(String[] args) {
SpringApplication.from(OrderApp::main)
.with(KafkaContainerDevMode.class)
.run(args);
}
}
Let’s run our three microservices. Just to remind you, it is possible to run the “test” main class directly from IDE or with the mvn spring-boot:test-run
command. As you see, I run all three apps.
Now, if we display a list of running containers, there is only one Kafka broker shared between all the apps.
Use Spring Boot support for Docker Compose
Beginning from version 3.1 Spring Boot provides built-in support for Docker Compose. Let’s switch to our last sample repository. It consists of several microservices that connect to the Mongo database and the Netflix Eureka discovery server. We can go to the directory with one of the microservices, e.g. customer-service
. Assuming we include the following Maven dependency, Spring Boot looks for a Docker Compose configuration file in the current working directory. Let’s activate that mechanism only for a specific Maven profile:
<profiles>
<profile>
<id>compose</id>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</profile>
</profiles>
Our goal is to run all the required external services before running the customer-service
app. The customer-service
app connects to Mongo, Eureka, and calls endpoint exposed by the account-service
. Here’s the implementation of the REST client that communicates to the account-service
.
@FeignClient("account-service")
public interface AccountClient {
@RequestMapping(method = RequestMethod.GET, value = "/accounts/customer/{customerId}")
List<Account> getAccounts(@PathVariable("customerId") String customerId);
}
We need to prepare the docker-compose.yml
with all required containers definition. As you see, there is the mongo
service and two applications discovery-service
and account-service
, which uses local Docker images.
version: "3.8"
services:
mongo:
image: mongo:5.0
ports:
- "27017:27017"
discovery-service:
image: sample-spring-microservices-advanced/discovery-service:1.0-SNAPSHOT
ports:
- "8761:8761"
healthcheck:
test: curl --fail http://localhost:8761/eureka/v2/apps || exit 1
interval: 4s
timeout: 2s
retries: 3
environment:
SPRING_PROFILES_ACTIVE: docker
account-service:
image: sample-spring-microservices-advanced/account-service:1.0-SNAPSHOT
ports:
- "8080"
depends_on:
discovery-service:
condition: service_healthy
links:
- mongo
- discovery-service
environment:
SPRING_PROFILES_ACTIVE: docker
Before we run the service, let’s build the images with our apps. We could as well use built-in Spring Boot mechanisms based on Buildpacks, but I’ve got some problems with it. Jib works fine in my case.
<profile>
<id>build-image</id>
<build>
<plugins>
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.3.2</version>
<configuration>
<to>
<image>sample-spring-microservices-advanced/${project.artifactId}:${project.version}</image>
</to>
</configuration>
<executions>
<execution>
<goals>
<goal>dockerBuild</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
Let’s execute the following command on the repository root directory:
$ mvn clean package -Pbuild-image -DskipTests
After a successful build, we can verify a list of available images with the docker images
command. As you see, there are two images used in our docker-compose.yml
file:
Finally, the only thing you need to do is to run the customer-service
app. Let’s switch to the customer-service
directory once again and execute the mvn spring-boot:run
with a profile that includes the spring-boot-docker-compose
dependency:
$ mvn spring-boot:run -Pcompose
As you see, our app locates docker-compose.yml
.
Once we start our app, it also starts all required containers.
For example, we can take a look at the Eureka dashboard available at http://localhost:8761
. There are two apps registered there. The account-service
is running on Docker, while the customer-service
has been started locally.
Final Thoughts
Spring Boot 3.1 comes with several improvements in the area of containerization. Especially the feature related to the ability to run Testcontainers in development together with the app was something that I was waiting for. I hope this article will clarify how you can take advantage of the latest Spring Boot features for better integration with Testcontainers and Docker Compose.
5 COMMENTS