Unit Integration Testing With Testcontainers Docker Compose
Is your test dependent on multiple other applications and do you want to create an integration test using Testcontainers? Then the Testcontainers Docker Compose Module is the solution. In this blog, you will learn how convenient it is to create an integration test using multiple Testcontainers. Enjoy!
Introduction
Using Testcontainers is a very convenient way to write a unit integration test. Most of the time, you will use it in order to test the integration with a database, a message bus, etc. But what if your application interacts with an application that consists of multiple containers? How can you add these containers to your unit integration test? The answer is quite simple: you should use the Docker Compose Module.
In the remainder of this blog, you will create a test that depends on another application that consists of two containers. Afterward, you will alter the unit tests using the Docker Compose Module.
The sources used in this blog are available on GitHub.
Prerequisites
Prerequisites for reading this blog are:
- Basic Java knowledge, Java 21 is used
- Basic Testcontainers knowledge
- Basic knowledge of Docker Compose
Application To Integrate With
You need an application to integrate with consisting out of more than one container. One of those applications is FROST-Server. FROST-Server is a server implementation of the OGC SensorThings API. The OGC SensorThings API is an OGC (Open Geospatial Consortium) standard specification for providing an open and unified way to interconnect IoT devices, data, and applications over the Web. The SensorThings API is an open standard applies an easy-to-use REST-like style. The result is to provide a uniform way to expose the full potential of the Internet of Things.
For this blog, you do not need to know what FROST-Server really is. But, the application can be started by means of a Docker Compose file and it consists out of two containers: one for the server and one for the database. So, this is interesting, because when you want to create an application which needs to interact with FROST-Server, you also want to create unit integration tests. FROST-Server provides a Rest API with CRUD operations for the SensorThings API objects.
The Docker Compose file consists of the FROST-Server itself and a PostgreSQL database. The PostgreSQL database has a health check and FROST-Server is dependent on the successful startup of the database.
services:
web:
image: fraunhoferiosb/frost-server:2.3.2
environment:
- serviceRootUrl=http://localhost:8080/FROST-Server
- plugins_multiDatastream.enable=false
- http_cors_enable=true
- http_cors_allowed_origins=*
- persistence_db_driver=org.postgresql.Driver
- persistence_db_url=jdbc:postgresql://database:5432/sensorthings
- persistence_db_username=sensorthings
- persistence_db_password=ChangeMe
- persistence_autoUpdateDatabase=true
ports:
- 8080:8080
- 1883:1883
depends_on:
database:
condition: service_healthy
database:
image: postgis/postgis:14-3.2-alpine
environment:
- POSTGRES_DB=sensorthings
- POSTGRES_USER=sensorthings
- POSTGRES_PASSWORD=ChangeMe
volumes:
- postgis_volume:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -d sensorthings -U sensorthings "]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgis_volume:
Start the Docker containers from the root of the Git repository.
$ docker compose up
Now that the application is running, you can write a unit integration test for this.
The test will:
- Send a POST request in order to create a SensorThings API
Thing
object. - Verify the successful creation of the
Thing
object (HTTP status 201 Created is received). - Send a GET request in order to retrieve the contents of the
Thing
object. - Verify the body of the GET request.
class ManualStartTest {
@Test
void testApp() throws Exception {
try (HttpClient client = HttpClient.newHttpClient()) {
String baseUrl = "http://localhost:8080/FROST-Server/v1.1/Things";
URI uri = URI.create(baseUrl);
// JSON payload to be sent in the request body
String postPayload = """
{
"name" : "Kitchen",
"description" : "The Kitchen in my house",
"properties" : {
"oven" : true,
"heatingPlates" : 4
}
}
""";
// Post the request
HttpRequest post = HttpRequest.newBuilder()
.uri(uri)
.method("POST", HttpRequest.BodyPublishers.ofString(postPayload))
.header("Content-Type", "application/json")
.build();
HttpResponse<String> response = client.send(post, HttpResponse.BodyHandlers.ofString());
assertEquals(201, response.statusCode());
// Get the Thing
URI getUri = URI.create(baseUrl + "(1)");
HttpRequest get = HttpRequest.newBuilder()
.uri(getUri)
.GET()
.header("Content-Type", "application/json")
.build();
String expected = "{\"@iot.selfLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)\",\"@iot.id\":1,\"name\":\"Kitchen\",\"description\":\"The Kitchen in my house\",\"properties\":{\"heatingPlates\":4,\"oven\":true},\"HistoricalLocations@iot.navigationLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)/HistoricalLocations\",\"Locations@iot.navigationLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)/Locations\",\"Datastreams@iot.navigationLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)/Datastreams\"}";
response = client.send(get, HttpResponse.BodyHandlers.ofString());
assertEquals(expected, response.body());
}
}
}
The test executes successfully, but you had to ensure that the FROST-Server was started manually. And this is not what you want: you want to use Testcontainers for it.
Stop the running containers with CTRL+C.
Use Docker Compose Module
In order to make use of the Docker Compose Module, you need to make some minor changes to the unit integration test.
In order to be able to use Testcontainers, you add the following dependencies to your pom.
<dependencyManagement>
<dependencies>
...
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.19.8</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
...
</dependencies>
You put the compose.yaml
file in the test resources directory: src/test/resources/compose.yaml
.
You annotate the unit test with the Testcontainers
annotation.
@Testcontainers
class TestcontainersComposeTest {
...
}
You create the DockerComposeContainer
environment:
- Create the environment with a reference to the Docker Compose file.
- By means of
withExposedService
, you indicate which services you want to add to your test. In this case thedatabase
service and theweb
service as defined in the Docker Compose file. - The
web
service is dependent on thedatabase
service as defined in the Docker Compose file, but you also need to wait for the successful startup of theweb
service before running the tests. Therefore, you define aWaitStrategy
for theweb
service. The easiest way to do so is to wait for a specific log message. - You annotate the environment with the
Container
annotation so that Testcontainers will manage the lifecycle of this environment.
@Container
private static final DockerComposeContainer environment =
new DockerComposeContainer(new File("src/test/resources/compose.yaml"))
.withExposedService("database", 5432)
.withExposedService("web",
8080,
Wait.forLogMessage(".*org.apache.catalina.startup.Catalina.start Server startup in.*\\n",
1));
As Testcontainers will run the containers on a random port, you also need to retrieve the host URL and port when running the test. You can do so by invoking the getServiceHost
and getServicePort
methods.
String frostWebUrl = environment.getServiceHost("web", 8080) + ":" + environment.getServicePort("web", 8080);
String baseUrl = "http://" + frostWebUrl + "/FROST-Server/v1.1/Things";
URI uri = URI.create(baseUrl);
The complete unit test is the following:
@Testcontainers
class TestcontainersComposeTest {
@Container
private static final DockerComposeContainer environment =
new DockerComposeContainer(new File("src/test/resources/compose.yaml"))
.withExposedService("database", 5432)
.withExposedService("web",
8080,
Wait.forLogMessage(".*org.apache.catalina.startup.Catalina.start Server startup in.*\\n",
1));
@Test
void testApp() throws Exception {
try (HttpClient client = HttpClient.newHttpClient()) {
String frostWebUrl = environment.getServiceHost("web", 8080) + ":" + environment.getServicePort("web", 8080);
String baseUrl = "http://" + frostWebUrl + "/FROST-Server/v1.1/Things";
URI uri = URI.create(baseUrl);
// JSON payload to be sent in the request body
String postPayload = """
{
"name" : "Kitchen",
"description" : "The Kitchen in my house",
"properties" : {
"oven" : true,
"heatingPlates" : 4
}
}
""";
// Post the request
HttpRequest post = HttpRequest.newBuilder()
.uri(uri)
.method("POST", HttpRequest.BodyPublishers.ofString(postPayload))
.header("Content-Type", "application/json")
.build();
HttpResponse<String> response = client.send(post, HttpResponse.BodyHandlers.ofString());
assertEquals(201, response.statusCode());
// Get the Thing
URI getUri = URI.create(baseUrl + "(1)");
HttpRequest get = HttpRequest.newBuilder()
.uri(getUri)
.GET()
.header("Content-Type", "application/json")
.build();
String expected = "{\"@iot.selfLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)\",\"@iot.id\":1,\"name\":\"Kitchen\",\"description\":\"The Kitchen in my house\",\"properties\":{\"heatingPlates\":4,\"oven\":true},\"HistoricalLocations@iot.navigationLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)/HistoricalLocations\",\"Locations@iot.navigationLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)/Locations\",\"Datastreams@iot.navigationLink\":\"http://localhost:8080/FROST-Server/v1.1/Things(1)/Datastreams\"}";
response = client.send(get, HttpResponse.BodyHandlers.ofString());
assertEquals(expected, response.body());
}
}
}
Run the test and you will have successfully executed a unit integration test using multiple Testcontainers!
Conclusion
Using Testcontainers for running an integration test against one container is pretty straightforward. But when you are in need of multiple Testcontainers for your unit integration test, then you can use the Docker Compose Module for that.