Testcontainers is a game-changing library for writing integration and end-to-end tests. With the advent of Docker, we can conveniently start infrastructure components like databases and messaging brokers without much effort on our machines. This helps to get our Spring Boot integration test setup as close to production as possible. With Testcontainers, we can manage the lifecycle of throwaway containers for testing purposes.
With this blog post, you'll get an introduction to Testcontainers and learn how to write integration tests for your Spring Boot application using this library.
Testcontainers Setup For Testing Spring Boot Applications
As a prerequisite for using Testcontainers, we have to ensure a Docker engine is running on our machine and available on our CI server:
1 | docker info |
Next, for Maven projects, we include the basic Testcontainers library as the following:
1 2 3 4 5 6 | <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <scope>test</scope> <version>1.16.0</verion> </dependency> |
For Gradle-based projects, it's the following line:
1 | testImplementation('org.testcontainers:testcontainers:1.15.1') |
For projects where we have multiple dependencies from Testcontainers, we must align their versions to avoid incompatibilities. Testcontainers provides a Maven Bill of Materials (BOM) for this purpose. Once we define the testcontainers-bom
as part of Maven’s dependencyManagement
section, we can include all Testcontainers dependencies without specifying their versions. The BOM will align all Testcontainers dependency versions for us:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | <dependencyManagement> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers-bom</artifactId> <version>1.16.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>kafka</artifactId> <scope>test</scope> // no version specified </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>mssql</artifactId> <scope>test</scope> // no version specified </dependency> </dependencies> |
Using Gradle, we include the BOM as the following:
1 2 3 | implementation platform('org.testcontainers:testcontainers-bom:1.16.0') testImplementation('org.testcontainers:kafka') testImplementation('org.testcontainers:mysql') |
Testcontainers also comes with first-class support for the following test frameworks: JUnit 4, JUnit Jupiter, and Spock.
1 2 3 4 5 6 | <dependency> <groupId>org.testcontainers</groupId> <!-- replace with spock for Spock support. For JUnit 4, no additional dependency is required --> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> |
Starting and Stopping a Container with Testcontainers
Let’s assume our application uses Keycloak as an identity provider. When writing end-to-end tests, we need a running instance of this identity provider.
With Testcontainers, we can now define a GenericContainer
as part of our test setup.
The constructor of this GenericContainer
expects and Docker image. For our example, we’ll use the official jboss/keycloak
image:
1 2 3 4 5 | class BasicContainerTest { static GenericContainer<?> keycloak = new GenericContainer<>(DockerImageName.parse("jboss/keycloak:11.0.0")); // ... our tests } |
With just this container definition, Testcontainers will not recognize this field definition as a Docker container to start (yet).
Using JUnit Jupiter, we can register the Testcontainers extension with @Testcontainers
. Next, we have to identify all our container definitions with @Container
:
1 2 3 4 5 6 7 | @Testcontainers class BasicContainerTest { @Container static GenericContainer<?> keycloak = new GenericContainer<>(DockerImageName.parse("jboss/keycloak:11.0.0")); } |
With this setup, Testcontainers will now start our Keycloak Docker container before executing the first test. All test methods inside this test class will share this container.
For more fine-grained control of when to start and stop the container, we can use the manual container lifecycle control:
1 2 3 4 5 6 7 8 9 | class BasicContainerTest { static GenericContainer<?> keycloak = new GenericContainer<>(DockerImageName.parse("jboss/keycloak:11.0.0")); static { keycloak.start(); } } |
Configure the Docker Container with Testcontainers
With this in setup place, how can Testcontainers know when our container is ready to receive traffic? Right now, it doesn’t.
That’s where the WaitStrategy
of Testcontainers comes into play. There are multiple wait strategies available:
- wait for a port to be available
- wait for an HTTP request to return a specific status code
- expect a value as part of the container’s log output
For our Keycloak instance, we can use the admin UI path /auth
as a readiness identifier.
Once this endpoint returns the HTTP status code 200, we can be sure our container started successfully:
1 2 3 | static GenericContainer<?> keycloak = new GenericContainer<>(DockerImageName.parse("jboss/keycloak:11.0.0")) .waitingFor(Wait.forHttp("/auth").forStatusCode(200)); |
Next, we can further tweak our container definition:
- define which ports to expose
- add environment variables
- map resources from the classpath to our container
- … and much more
For our Keycloak example, we can expose port 8080 and define the credentials for the Keycloak admin user as part of the environment:
1 2 3 4 5 6 7 8 9 10 | static GenericContainer<?> keycloak = new GenericContainer<>(DockerImageName.parse("jboss/keycloak:11.0.0")) .waitingFor(Wait.forHttp("/auth").forStatusCode(200)) .withExposedPorts(8080) .withClasspathResourceMapping("/config/test.txt", "/tmp/test.txt", BindMode.READ_WRITE) .withEnv(Map.of( "KEYCLOAK_USER", "testcontainers", "KEYCLOAK_PASSWORD", "testcontainers", "DB_VENDOR", "h2" )); |
Upon test execution, Testcontainers will pick a random ephemeral port for any of our exposed ports to avoid port clashes.
When running a test class with the container setup above, we’ll see the following two containers on our machine:
1 2 3 4 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 33ecf1f2d3e0 jboss/keycloak:11.0.0 "/opt/jboss/tools/do…" 15 seconds ago Up 14 seconds 0.0.0.0:32770->8080/tcp, 0.0.0.0:32769->8443/tcp friendly_ritchie 42eaa5c2ffea testcontainers/ryuk:0.3.0 "/app" 16 seconds ago Up 15 seconds 0.0.0.0:32768->8080/tcp testcontainers-ryuk-0048a |
The second Ryuk container acts as a helper for housekeeping tasks (deleting container/volumes/networks) after the test.
Once the container is up and running, we can retrieve the mapped system port and interact with the container. This allows us to, e.g., retrieve the stdout/stderr
from the container, execute commands inside the container, etc.:
1 2 3 4 5 6 7 | @Test void testWithKeycloak() throws IOException, InterruptedException { ExecResult execResult = keycloak .execInContainer("/bin/sh", "-c", "echo \"Admin user is $KEYCLOAK_USER\""); System.out.println("Result: " + execResult.getStdout()); System.out.println("Keycloak is running on port: " + keycloak.getMappedPort(8080)); } |
At the end of the test Testcontainers, will ensure to stop and remove our containers. This happens either right before the JVM exits or as part of @AfterAll
when using the JUnit Jupiter extension. This housekeeping ensures we don’t have any running containers on our machine.
Pretty impressive, isn’t it?
Using Modules From Testcontainers
With the GenericContainer
, we can go pretty far and tweak our container definition to our needs. However, we might find ourselves repeatedly doing the same container definition for multiple projects.
Fortunately, Testcontainers provides a set of modules for convenient bootstrapping of common Docker containers. The list of available modules is enormous (especially for databases). There are ready-to-use modules available for Kafka, PostgreSQL, Webdriver, RabbitMQ, Elasticsearch, etc.
If we don't find ourselves lucky to use one of these modules, we can also create our own and share it inside our cooperation or publicly. Furthermore, excellent community Testcontainers modules are available, like Wim Deblauwe's Cypress module or a Keycloak module from Niko Köbler.
For demonstration purposes, let's use the PostgreSQL
module to test our project's persistence layer. We can include any Testcontainers module as an additional dependency:
1 2 3 4 5 6 7 | <dependency> <groupId>org.testcontainers</groupId> <!-- name of the module --> <artifactId>postgresql</artifactId> <version>1.16.0</version> <!-- omit the version if you use the BOM --> <scope>test</scope> </dependency> |
This dependency includes the PostgreSQLContainer
class for a convenient PostgreSQL database definition. Depending on which module we use, the container classes provide additional helper methods to set up the container easily.
In the PostgreSQLContainer
example, we use .withUsername()
to define PostgreSQL's admin user and don't have to remember any environment variables or configuration files that make this happen:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | @Testcontainers class ModuleContainerTest { @Container static PostgreSQLContainer database = new PostgreSQLContainer<>("postgres:12") .withUsername("duke") .withPassword("secret") .withInitScript("config/INIT.sql") .withDatabaseName("tescontainers"); @Test void testPostgreSQLModule() { System.out.println(database.getJdbcUrl()); System.out.println(database.getTestQueryString()); // jdbc:postgresql://localhost:32827/tescontainers?loggerLevel=OFF // SELECT 1 } } |
Now it's time to integrate Testcontainers for our Spring Boot integration tests.
Spring Boot Integration Tests: Testcontainers and JUnit 4
Using: JUnit 4.12 and Spring Boot < 2.2.6
For demonstration purposes, let's assume we want to write an integration test for our Spring Boot application that connects to a PostgreSQL database. We use the Testcontainers PostgreSQL module to avoid any manual GenericContainer
definitions.
As our application expects a running PostgreSQL database upon application startup (to create the DataSource
), we define a PostgreSQLContainer
for our test. With the help of Testcontainers JUnit 4 support, the @ClassRule
ensures to start our database container before the Spring context launches.
Testcontainers maps the PostgreSQL's main port (5432) to a random and ephemeral port, so we must override our configuration dynamically.
For Spring Boot applications < 2.2.6, we can achieve this with an ApplicationContextInitializer
and set the connection parameters dynamically:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ContextConfiguration(initializers = JUnit4ApplicationTest.Initializer.class) public class JUnit4ApplicationTest { @ClassRule public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:12") .withPassword("inmemory") .withUsername("inmemory"); public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { @Override public void initialize(ConfigurableApplicationContext configurableApplicationContext) { TestPropertyValues values = TestPropertyValues.of( "spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(), "spring.datasource.password=" + postgreSQLContainer.getPassword(), "spring.datasource.username=" + postgreSQLContainer.getUsername() ); values.applyTo(configurableApplicationContext); } } @Test public void contextLoads() { } } |
With this setup in place, we can now start the test and see a successful execution.
For a hands-on demonstration for this setup, consider watching this video.
Spring Boot Integration Tests: Testcontainers and JUnit 5
For applications that use JUnit Jupiter (part of JUnit 5), we can't use the @ClassRule
anymore. The extension model of JUnit Jupiter exceeds the rule/runner API from JUnit 4.
As already outlined in the Testcontainers introduction section, there's support for JUnit Jupiter available:
1 2 3 4 5 6 | <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> </dependency> |
With this dependency and a more recent version of Spring Boot (> 2.2.6), the same integration test looks like the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @Testcontainers @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class JUnit5ApplicationTest { @Container static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:12") .withPassword("inmemory") .withUsername("inmemory"); @DynamicPropertySource static void postgresqlProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); registry.add("spring.datasource.password", postgreSQLContainer::getPassword); registry.add("spring.datasource.username", postgreSQLContainer::getUsername); } @Test void contextLoads() { } } |
With the help of @DynamicPropertySource
we can dynamically override the datasource
connection parameters.
If our application uses a Spring Boot version before 2.2.6, we don't have access to the @DynamicPropertySource
feature. For such applications, we can fallback to the ApplicationContextInitializer
setup approach:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // JUnit 5 example with Spring Boot < 2.2.6 @Testcontainers @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ContextConfiguration(initializers = AlternativeJUnit5ApplicationTest.Initializer.class) class AlternativeJUnit5ApplicationTest { @Container static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:12") .withPassword("inmemory") .withUsername("inmemory"); public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { @Override public void initialize(ConfigurableApplicationContext configurableApplicationContext) { TestPropertyValues values = TestPropertyValues.of( "spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(), "spring.datasource.password=" + postgreSQLContainer.getPassword(), "spring.datasource.username=" + postgreSQLContainer.getUsername() ); values.applyTo(configurableApplicationContext); } } @Test void contextLoads() { } } |
Further Content On Testcontainers
For more information and examples on using Testcontainers, consider the following articles:
- Spring Boot Functional Tests with Selenium and Testcontainers
- Initialization Strategies With Testcontainers For Integration Tests
- Testing Spring Boot Applications with Kotlin and Testcontainers
- Reuse Containers With Testcontainers for Fast Integration Tests
Also, check out my YouTube channel for hands-on Testcontainers examples.
The source code for this Testcontainers introduction for Testing Spring Boot Applications is available on GitHub.
As part of the Testing Spring Boot Applications Masterclass, we make heavy use of Testcontainers to test a real-world Spring Boot application. We'll also do a deep-dive on Testcontainers and how to use some of its advanced features (starting a Docker Compose environment, reusable containers, etc.) for writing integration and end-to-end tests.
Happy integration-testing with Spring Boot, Testcontainers, and JUnit,
Philip