Testcontainers in Java and Spring Boot

Nil Seri
4 min readFeb 13, 2024

--

A Quick Introduction To Testcontainers

Photo by Karl Hedin on Unsplash

What are Testcontainers?

Testcontainers is an open source framework for providing throwaway, lightweight instances of databases, message brokers, web browsers, or just about anything that can run in a Docker container — you should have Docker installed and it must be up and running as a prerequisite.

They can be used in integration tests or local development with real dependencies. In this post, we will mainly focus on testing.

These containers will be started before any tests in the class run, and will be destroyed after all tests have run.

Quick Start

You can create a container with extra options (such as an environment variable or command) provided:

public static final DockerImageName ALPINE_IMAGE = DockerImageName.parse("alpine:3.19.1");
// Set up a plain OS container and customize environment,
// command and exposed ports. This just listens on port 80
// and always returns '42'
@ClassRule
public static GenericContainer<?> alpine = new GenericContainer<>(ALPINE_IMAGE)
.withExposedPorts(80)
.withEnv("MAGIC_NUMBER", "42")
.withCommand("/bin/sh", "-c",
"while true; do echo \"$MAGIC_NUMBER\" | nc -l -p 80; done");

If the tests require more complex services, we can specify them in a docker-compose file using DockerComposeContainer.

For this, you do not need to add any extra dependency since Docker Compose support is part of the core Testcontainers library.

test-compose.yml file content:

redis:
image: redis
nginx:
image: nginx

It is not necessary to define ports to be exposed in the YAML file. Testcontainers will spin up a small ‘ambassador’ container (a separate, minimal container that runs socat as a TCP proxy), which will proxy between the Compose-managed containers and ports that are accessible to your tests.

public static final Integer REDIS_PORT = 6379;
public static final Integer NGINX_PORT = 80;

@ClassRule
public static DockerComposeContainer environment =
new DockerComposeContainer(new File("src/test/resources/test-compose.yml"))
.withExposedService("redis_1", REDIS_PORT)
.withExposedService("nginx_1", NGINX_PORT);

You should wait for the container to be ready before use:
- Wait Strategy: check if we can talk to the container over the network.
- Startup strategy: check if the container reached to the desired running state.

Wait Strategy
Testcontainers will wait for up to 60 seconds for the container’s first mapped network port to start listening.

You can wait for an HTTP(S) endpoint (can also be different than ‘/’) to return a particular status code:

public GenericContainer nginxWithHttpWait = new GenericContainer(DockerImageName.parse("nginx:1.9.4"))
.withExposedPorts(80)
.waitingFor(Wait.forHttp("/").forStatusCode(200));

If the used image supports Docker’s Healthcheck feature, you can directly leverage the healthy state of the container as your wait condition:

public GenericContainer nginxWithHttpWait = new GenericContainer(DockerImageName.parse("nginx:1.9.4"))
.withExposedPorts(80)
.waitingFor(Wait.forHealthcheck());

You can also check for a log to be printed to determine if the container is ready or not:

public GenericContainer containerWithLogWait = new GenericContainer(DockerImageName.parse("redis:5.0.3"))
.withExposedPorts(6379)
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));

If you have used Docker Compose (different strategies can be defined for each container):

public static final Integer REDIS_PORT = 6379;
public static final Integer NGINX_PORT = 80;

@ClassRule
public static DockerComposeContainer environment =
new DockerComposeContainer(new File("src/test/resources/test-compose.yml"))
.withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort())
.withExposedService("nginx_1", NGINX_PORT,
Wait.forHttp("/")
.forStatusCode(200)
.forStatusCode(401)
.usingTls());

Startup Strategy
Testcontainers will check that the container has reached the running state and has not exited. In order to do that inspect is executed against the container and state parameter is extracted.

To check if a container is running (and has not exited):

public GenericContainer containerWithLogWait = new GenericContainer(DockerImageName.parse("redis:5.0.3"))
.withExposedPorts(6379)
.withStartupCheckStrategy(new MinimumDurationRunningStartupCheckStrategy(Duration.ofSeconds(20)));

Depending on Another Container
You can tell a container that it depends on another container by using the dependsOn method:

public GenericContainer<?> redis = new GenericContainer<>("redis:3.0.2").withExposedPorts(6379);

@Rule
public GenericContainer<?> nginx = new GenericContainer<>("nginx:1.9.4").dependsOn(redis).withExposedPorts(80);

Exposed Port and Mapped Port
Exposed port number is from the perspective of the container. From the host’s perspective, it actually exposes on a random free port to avoid port collisions.

You can get the actual mapped port at runtime. This can be done using the getMappedPort method —the container must be running at the time, which takes the original (container) port as an argument:

// get mapped port
Integer mappedPort = alpine.getMappedPort(80);
// get ip address
String ipAddress = alpine.getHost();

String address = ipAddress + ":" + mappedPort;

If you have used Docker Compose, you can get (via the ambassador container) the IP address and mapped port as the following:

public static final Integer REDIS_PORT = 6379;
...
String redisUrl = environment.getServiceHost("redis_1", REDIS_PORT)
+ ":" +
environment.getServicePort("redis_1", REDIS_PORT);

Executing a Command
You can execute a command inside a running container, similar to “docker exec”:

alpine.execInContainer("touch", "/myfile.txt");

You can also read the result after command execution:

Container.ExecResult lsResult = container.execInContainer("ls", "-al", "/");
String stdout = lsResult.getStdout();
int exitCode = lsResult.getExitCode();
assertThat(stdout).contains("output.txt");
assertThat(exitCode).isZero();

Capture Output
You can capture container output for an expected content with WaitingConsumer. It will block until a frame of container output (usually a line) matches a provided predicate — unless a timeout is specified:

WaitingConsumer consumer = new WaitingConsumer();
container.followOutput(consumer, STDOUT);
consumer.waitUntil(frame ->
frame.getUtf8String().contains("STARTED"), 30, TimeUnit.SECONDS);

File Operations
Copying a file to a container (to a specified location with file mode) before startup:

GenericContainer<?> container = new GenericContainer<>(TestImages.TINY_IMAGE)
.withStartupCheckStrategy(new NoopStartupCheckStrategy())
.withCopyToContainer(Transferable.of("test.txt", 0777), "/tmp/test.txt")
.waitingFor(new WaitForExitedState(state -> state.getExitCodeLong() > 0))
.withCommand("sh", "-c", "ls -ll /tmp | grep '\\-rwxrwxrwx\\|test' && exit 100")

Copying a file from a container after the container has started:

container.copyFileFromContainer(directoryInContainer + fileName, destinationOnHost);

Spring Boot Testcontainers Support

Spring Boot 3.1.0 introduced better support for Testcontainers that simplifies test configuration greatly.

Previously, you would have to use “@DynamicPropertySource” annotation in order to add port related properties to a DynamicPropertyRegistry (since the port exposed by the container will be dynamically allocated).

For this, you need to add the following dependency to your project:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>

Now, you can annotate the container declaration in your code with “@ServiceConnection” to eliminate the boilerplate code of defining the dynamic properties:

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ServiceConnectionIntegrationTest {

@Container
@ServiceConnection
static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));

@Test
void test() {
...
}
}

Happy Coding!

--

--

Nil Seri

I would love to change the world, but they won’t give me the source code | coding 👩🏻‍💻 | coffee ☕️ | jazz 🎷 | anime 🐲 | books 📚 | drawing 🎨