Stateful Testcontainers for Spring Boot 3.1 Dev Mode
Configuring containers to keep the state between executions to improve the DevEx. Using MongoDB as example.
Spring Boot 3.1 introduced a lot of enhancements for DevEx. In particular, their integration with Testcontainers for test and DevMode.
DevMode
Now, you can declare your infrastructure dependencies and run your application in local machine by declaring via Spring Beans which Containers to use.
Something like:
package dev.aleixmorgadas;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.devtools.restart.RestartScope;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.List;
@TestConfiguration(proxyBeanMethods = false)
public class TestMyApplication {
public static void main(String[] args) {
SpringApplication.from(MyApplication::main)
.with(TestMyApplicationon.class).run(args);
}
@Bean
@RestartScope
@ServiceConnection
MongoDBContainer mongoContainer() {
return new MongoDBContainer(DockerImageName.parse("mongo:6.0.8"));
}
}
With this code, we are telling Spring to use a MongoContainer and replace the configuration with the Container one via `@ServiceConnection
` annotation.
Shortly after using it you will find something frustrating.
Your database state disappeared after stopping the application.
The missing developer experience - Stateful containers
Compared to tests that you want to start clean on each execution, when developing in local you aim to keep the state between runs.
Here we have two options:
Testcontainers reuse feature. It tells Testcontainers to not stop the container, and when we start again, it will reuse the same container instance.
Add a volume.
1. Reuse container
Let’s declare the mongoContainer
as a reusable with a simple flag.
@Bean
@RestartScope
@ServiceConnection
MongoDBContainer mongoContainer() {
return new MongoDBContainer(DockerImageName.parse("mongo:6.0.8"))
.withReuse(true);
}
Pro
The container will keep its state between executions.
Cons
If we restart our computer or just stop the container, we will lose the sate.
2. Adding a volume
MongoClient example
@Bean
@RestartScope
@ServiceConnection
MongoDBContainer mongoContainer() {
return new MongoDBContainer(DockerImageName.parse("mongo:6.0.8")) {
@Override
public void configure() {
withFileSystemBind("./.data", "/data/db");
waitingFor(Wait.forLogMessage("(?i).*waiting for connections.*", 1));
}
@Override
protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) {
// Disable default configuration due to replica set breaking configuration with stateful approach
}
};
}
Postgres example
@Bean
@RestartScope
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:15"))
.withFileSystemBind("./.data", "/var/lib/postgresql/data");
}
Pro
It keeps the state between executions and keeps the data after stopping the container.
Cons
You have state 🤷♂️.
At this point, we fixed the problem with the state, but if we are developing in local, we might need to access the database to check anything.
Accessing the database painlessly - Fixed Random Ports
Testcontainers was originally intended for tests (and lucky us, we are seeing that it applies to more areas than just tests), it has some design decisions meant for testing best practices.
One of those best practices are random ports.
From the host's perspective Testcontainers actually exposes this on a random free port. This is by design, to avoid port collisions that may arise with locally running software or in between parallel test runs.
This is a great decision from a Testing point of view, and I fully recommend following this approach. BUT, when I’m developing in local, I don’t want to change my DB Tool each time the port changes.
We have two options:
Fixed ports
1. Fixed ports
This is the fastest to apply, into your DevMode Configuration.
@Bean
@RestartScope
@ServiceConnection
MongoDBContainer mongoContainer() {
var container = new MongoDBContainer(DockerImageName.parse("mongo:6.0.8"));
container.setPortBindings(List.of("55555:27017"));
return container;
}
Pros
You don’t need to change your connection confirguration each time you start your application.
Cons
You might have port collitions.
2. Testcontainers Desktop
Testcontainers is well aware of this problem and they provided a solution for it via the Testcontainers Desktop.
You can see the full feature description here.
It added a new option called `Development Services` in the application.
Which comes with a lot of preconfigured modules which what they do is map the random port to the prefeered port of the infrastructure you are running.
Here an Redis example of how it is configured for you as default config.
Pros
You don’t need to change your connection confirguration each time you start your application.
Cons
You need to install Testcontainers Desktop. None 😄