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 TestcontainersGenericContainer 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.

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:

  1. BeforeAllCallback
  2. @BeforeAll
  3. BeforeEachCallback
  4. @BeforeEach
  5. BeforeTestExecutionCallback
  6. @Test
  7. AfterTestExecutionCallback
  8. @AfterEach
  9. AfterEachCallback
  10. @AfterAll
  11. 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. CosmosDBEmulatorContainerBootstrap JUPITER/JUNIT 5 EXTENSION

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 Path tempFolder;
  private GenericContainer<?> cosmosDbContainer;

  @Override
  public void beforeAll(ExtensionContext context) throws Exception {
    // Sets up, starts Docker container, truststore, system properties
    if (IS_INITIALIZED.compareAndSet(false, true)) {
      this.setupAndStartContainer();

      String truststoreFilename = this.setupTruststore();
      context.getStore(ExtensionContext.Namespace.GLOBAL).put("TRUSTSTORE_FILEPATH", truststoreFilename);

      this.addCosmosDbProperties();
    }
  }

  private void setupAndStartContainer() 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.getByName(
          DockerClientFactory.instance().dockerHostIpAddress()).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.cosmosDbContainer.start();
  }

  private String setupTruststore() throws Exception {
    this.tempFolder = Files.createTempDirectory(TRUSTSTORE_TEMP_FOLDER_PREFIX);
    Runtime.getRuntime().addShutdownHook(new Thread(() -> FileUtils.deleteQuietly(this.tempFolder.toFile())));

    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());
  }

}

These are some improvements to the Cosmos DB emulator Testcontainers’ 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.
  • Added support to start the Cosmos DB emulator in a remote Docker host.
  • 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 Cosmos DB emulator UI Home

A key aspect of the JUnit 5 BeforeAllCallback.beforeAll() implementation is using the IS_INITIALIZED AtomicBoolean flag to execute the initialization block a single time.

This ensures the Cosmos DB emulator starts, the truststore is setup, and the spring.cloud.azure.cosmos.* test properties are updated just once across all tests, a recommended practice.

Implementation details include:

  • 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 JUnit 5 Extensions to use the truststore we created.

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"));
  }
// ...
}

It adds the CosmosDBEmulatorContainerBootstrapExtension class to the @ExtendWith annotation.

And you don’t need the @DynamicPropertySource-annotated method or statements that update the Cosmos DB properties anymore.

These are the essential pieces for writing Spring Boot integration tests with Testcontainers and JUnit 5 Extensions.

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 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.

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.