Search results
Reuse Testcontainers initialization and configuration code with JUnit 5 Extension Callbacks in your Spring Boot Integration Tests
1. OVERVIEW
You are writing integration tests for your Spring Boot applications with Jupiter/JUnit 5
and Testcontainers.
Take for instance this test class from Writing integration tests with Testcontainers and Microsoft’s Cosmos DB Docker emulator for Spring Boot applications:
UserControllerTestContainersIntegrationTest.java
:
// ...
@Testcontainers
public class UserControllerTestContainersIntegrationTest {
@Container
public final static AsimioCosmosDBEmulatorContainer COSMOS_DB_EMULATOR = new AsimioCosmosDBEmulatorContainer();
@BeforeAll
public static void setupTruststore() throws Exception {
// ...
KeyStoreUtils.extractPublicCert(COSMOS_DB_EMULATOR.getHost(), COSMOS_DB_EMULATOR.getEmulatorEndpointPort(), pemCertPath);
String trustStorePath = TEMP_FOLDER.getPath() + File.separator + "integration-tests-cosmos-emulator.truststore";
KeyStoreUtils.createKeyStore(
Lists.newArrayList(pemCertPath),
trustStorePath,
"changeit"
);
System.setProperty("javax.net.ssl.trustStore", trustStorePath);
System.setProperty("javax.net.ssl.trustStorePassword", "changeit");
System.setProperty("javax.net.ssl.trustStoreType", "PKCS12");
}
@DynamicPropertySource
public static void cosmosDbProperties(DynamicPropertyRegistry registry) throws Exception {
registry.add("spring.cloud.azure.cosmos.endpoint", COSMOS_DB_EMULATOR::getEmulatorEndpoint);
}
@Test
public void shouldRetrieveUserWithIdEqualsTo100() {
// ...
}
// ...
}
The AsimioCosmosDBEmulatorContainer class extends from Testcontainers’ GenericContainer class because its instantiation and configuration got too complex and noisy.
It makes the code easier to read, maintain, while also helping you to prevent Copy and Paste code in case you need to write more Spring Boot, Cosmos DB-based integration test classes.
Also, notice the custom truststore setup for the tests to run. That’s because every time a Cosmos DB emulator Docker container starts, it generates a different self-signed certificate.
Which by the way, if your Spring Boot integration tests not only connects to a Cosmos DB emulator Docker container, but also to other Docker containers that also generate self-signed certs, you would also need to add them to the same truststore your tests use, making the testing setup even more complex.
And lastly, note the @DynamicPropertySource piece needed for the Spring Boot integration tests to know the Cosmos DB emulator endpoint’s random Host port they’ll send requests to.
Now the questions that drove me write this blog post:
- What if you need to write a couple of dozen integration tests using Testcontainers with a setup similar to the one I described above?
- How do you run only one Docker container for the tests suite to use instead of starting a new Docker container for each integration test class?
- How do you write all these Testcontainers’ instantiation and configuration code in a reusable fashion?
- How do your integration tests or Docker containers share a kind of test context (ExtensionContext in Jupiter/JUnit
5
parlance) with resources they might need? For instance, a truststore that different Docker containers need to add a self-signed certificate to.
One approach to writing reusable Testcontainers instantiation and configuration code is to use Jupiter/JUnit 5
Extension Callbacks. And that’s what this blog post covers.
This blog post doesn’t cover declaring the Jupiter/JUnit
5
, and Testcontainer dependencies, the Cosmos DB container documents classes, test configuration properties, or seeding your Cosmos DB container with data for every test.
You can learn about these topics if you read on:
Writing Spring Boot integration tests with Testcontainers and Cosmos DB Docker emulator.
Seeding Cosmos DB Data to run Spring Boot Integration Tests.
2. JUNIT 5 LIFECYCLE CALLBACKS: BeforeAllCallback
A brief primer without digging into Jupiter/JUnit 5
extension model.
You are probably familiar with these JUnit 5
lifecycle methods:
@BeforeAll |
@AfterAll |
@BeforeEach |
@AfterEach |
Jupiter/JUnit 5
also includes lifecycle callbacks extensions like:
BeforeAllCallback |
AfterAllCallback |
BeforeEachCallBack |
AfterEachCallback |
BeforeTestExecutionCallback |
AfterTestExecutionCallback |
Their order of execution in an @Test-annotated method is:
- BeforeAllCallback
- @BeforeAll
- BeforeEachCallback
- @BeforeEach
- BeforeTestExecutionCallback
- @Test
- AfterTestExecutionCallback
- @AfterEach
- AfterEachCallback
- @AfterAll
- AfterAllCallback
We’ll focus on the BeforeAllCallback interface to write a Jupiter/JUnit 5
callback extension that will configure, and start the Cosmos DB emulator Docker container, and set other things up, only once, for different integration test classes to use.
3. CosmosDBEmulatorContainer BOOTSTRAP EXTENSION IMPLEMENTATION
Let’s write a Jupiter/JUnit 5
Extension that implement the BeforeAllCallback interface:
CosmosDBEmulatorContainerBootstrapExtension.java
:
public class CosmosDBEmulatorContainerBootstrapExtension implements BeforeAllCallback {
private static final AtomicBoolean IS_INITIALIZED = new AtomicBoolean(false);
private static final String COSMOSDB_IMAGE_NAME = "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator";
private static final DockerImageName COSMOSDB_IMAGE = DockerImageName.parse(COSMOSDB_IMAGE_NAME);
// ...
private final Path tempFolder;
private final GenericContainer<?> cosmosDbContainer;
public CosmosDBEmulatorContainerBootstrapExtension() throws IOException {
this.cosmosDbContainer = new GenericContainer(COSMOSDB_IMAGE) {
public GenericContainer addFixedPorts() {
this.addFixedExposedPort(emulatorEndpointPort, emulatorEndpointPort);
this.addFixedExposedPort(mongoNoGeoReplicationPort, mongoNoGeoReplicationPort);
this.addFixedExposedPort(directPort1, directPort1);
this.addFixedExposedPort(directPort2, directPort2);
this.addFixedExposedPort(directPort3, directPort3);
this.addFixedExposedPort(directPort4, directPort4);
return this;
}
}.addFixedPorts()
.withEnv(Map.of(
"AZURE_COSMOS_EMULATOR_ARGS", String.format(
"/Port=%s /MongoPort=%s /DirectPorts=%s,%s,%s,%s",
emulatorEndpointPort, mongoNoGeoReplicationPort, directPort1, directPort2, directPort3, directPort4
),
"AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE", InetAddress.getLocalHost().getHostAddress(),
"AZURE_COSMOS_EMULATOR_PARTITION_COUNT", "1",
"AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", "false")
)
.waitingFor(new WaitAllStrategy(WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY)
.withStrategy(Wait.forLogMessage("(?s).*Started\\r\\n$", 1))
.withStrategy(Wait.forHttps("/_explorer/index.html")
.forPort(emulatorEndpointPort)
.allowInsecure()
.withRateLimiter(RateLimiterBuilder.newBuilder()
.withRate(12, TimeUnit.MINUTES) // 12 per minute, one request every 5 seconds
.withConstantThroughput()
.build()
)
.withStartupTimeout(Duration.ofSeconds(120)) // 2 minutes
)
);
this.tempFolder = Files.createTempDirectory(TRUSTSTORE_TEMP_FOLDER_PREFIX);
Runtime.getRuntime().addShutdownHook(new Thread(() -> FileUtils.deleteQuietly(this.tempFolder.toFile())));
}
@Override
public void beforeAll(ExtensionContext context) throws Exception {
// Sets up Docker container, truststore, system properties
if (IS_INITIALIZED.compareAndSet(false, true)) {
this.cosmosDbContainer.start();
String truststoreFilename = this.setupTruststore();
context.getStore(ExtensionContext.Namespace.GLOBAL).put("TRUSTSTORE_FILEPATH", truststoreFilename);
this.addCosmosDbProperties();
}
}
private String setupTruststore() throws Exception {
String pemCertPath = this.tempFolder + File.separator + "cosmos-db-emulator.pem";
// ...
System.setProperty("javax.net.ssl.trustStore", trustStorePath);
System.setProperty("javax.net.ssl.trustStorePassword", "changeit");
System.setProperty("javax.net.ssl.trustStoreType", "PKCS12");
return trustStorePath;
}
private void addCosmosDbProperties() {
// No need for @DynamicPropertySource-spring.cloud.azure.cosmos.endpoint anymore
System.setProperty("spring.cloud.azure.cosmos.endpoint", this.getEmulatorEndpoint());
}
}
We initialize the Cosmos DB emulator Testcontainer in the constructor itself this time, instead of writing a class that extends from GenericContainer.
Some improvements to the Cosmos DB emulator Testcontainer’s instantiation and configuration you can find in the new released version of the source code (if you decide to buy it) are:
- The emulator endpoint, MongoDB, and Direct Ports are now dynamic, instead of hard-coding them.
- A ShutdownHook registration to delete the temporal truststore file and its parent folder.
- The WaitStrategy also includes waiting up to a couple of minutes until the port the emulator endpoint listens on becomes available, which is not always the case even after you see these Docker container startup logs:
... org.testcontainers.containers.output.WaitingConsumer - STDOUT: Started 1/2 partitions
...
... org.testcontainers.containers.output.WaitingConsumer - STDOUT: Started 2/2 partitions
... org.testcontainers.containers.output.WaitingConsumer - STDOUT: Started
Cosmos DB emulator UI Home
Key and relevant points to this JUnit 5
BeforeAllCallback.beforeAll() method implementation approach are:
- The
IS_INITIALIZED
AtomicBoolean flag takes care of setting things up just once, such as:- Explicitly starts the Cosmos DB emulator Docker container.
- Creates the custom truststore in a temporal folder for the integration tests to be able to connect with the Cosmos DB emulator Docker container via
HTTPS
. - Sets the truststore and
spring.cloud.azure.cosmos.endpoint
system properties. - Stores the truststore full path name in the beforeAll(ExtensionContext context) method parameter.
We did this in case another Docker container also uses a self-signed certificate that needs to be added to the same truststore.
You can use ExtensionContext for other purposes too. In our case, we are allowing other JUnit5
Extensions to use the truststore we created.
It’s worth mentioning that sharing the Cosmos DB emulator Docker container that started just once between your integration test classes is a recommended practice.
The Cosmos DB emulator Docker container will run just once, during the Maven test phase, or during the integration-test phase, if you decide to split running unit and integration tests using the maven-surefire-plugin.
We also left out of the code snippet above how to extract a certificate and add it to a truststore programmatically.
The accompanying source code uses Spring Boot
2.7.18
.
Stay tuned and sign up to the newsletter, I might cover Spring Boot
3.1
’s @ServiceConnection new annotation. @ServiceConnection simplifies configuration for test and development environments that use Testcontainers.
4. INTEGRATION TEST CLASS
Let’s now go over a Spring Boot-based integration test class that uses the CosmosDBEmulatorContainerBootstrapExtension.
UserControllerTestContainersJUnit5ExtensionIntegrationTest.java
:
@ExtendWith({ SpringExtension.class, CosmosDBEmulatorContainerBootstrapExtension.class })
@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(classes = { Application.class })
@ActiveProfiles("integration-test")
public class UserControllerTestContainersJUnit5ExtensionIntegrationTest {
@LocalServerPort
private int port;
@BeforeEach
public void beforeEach() {
RestAssured.port = this.port;
}
@Test
public void shouldRetrieveUserWithIdEqualsTo100() {
JsonPath jsonPath = RestAssured
.given()
.accept(ContentType.JSON)
.when()
.get("/api/users/100")
.then()
.statusCode(HttpStatus.OK.value())
.contentType(ContentType.JSON)
.extract()
.jsonPath();
Map<String, Object> actualUser = jsonPath.get("$");
MatcherAssert.assertThat(actualUser.get("id"), Matchers.equalTo("100"));
MatcherAssert.assertThat(actualUser.get("firstName"), Matchers.equalTo("First-100"));
}
// ...
}
Notice the @ExtendWith annotation including the CosmosDBEmulatorContainerBootstrapExtension class. That’s all you really need to write integration tests that interact with the Cosmos DB emulator Docker container using JUnit 5
Extensions. That is it.
Running the test methods from the IDE results in success:
Reuse Testcontainers initialization and configuration code with JUnit 5 Extension Callbacks in your Spring Boot Integration Tests
5. CONCLUSION
This blog post covered how to write Jupiter/JUnit 5
Extensions for Testcontainers to reuse code between different Spring Boot integration test classes.
It focussed on the beforeAll() lifecycle Extension and Azure Cosmos DB emulator Docker container, but you could use the same idea and concepts for other Docker containers, including combining multiple containers your Spring Boot tests might depend on.
Writing your own JUnit 5
Testcontainers Extensions also helps you to reduce your tests suite execution time because you can reuse the same Docker container(s) for all your test classes.
And it also briefly touched sharing the ExecutionContent between different Extensions so that Extensions can access and use resources already set up by a prior Extension.
Writing integration tests with Testcontainers and Azure Cosmos DB Docker emulator for Spring Boot applications.
Thanks for reading and as always, feedback is very much appreciated. If you found this post helpful and would like to receive updates when content like this gets published, sign up to the newsletter.
6. SOURCE CODE
Your organization can now save time and costs by purchasing a working code base, clean implementation, with support for future revisions.