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

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