Reducing Testcontainers Execution Time with JUnit 5 Callbacks

Last Updated:  July 3, 2024 | Published: July 3, 2024

When writing integration tests with Testcontainers, a significant portion of the code gets dedicated to declaring, configuring, and starting the required container instances. Additionally, we make use of the @DynamicPropertySource annotation to define custom or Spring-specific configuration properties needed by the application context to start up correctly.

While this boilerplate code for container setup and configuration may seem manageable when dealing with a single test class, the issue becomes more apparent when we work with multiple test classes that directly or indirectly require the same container(s) to be up and running.

In such cases, we face issues of:

  • Repetitive boilerplate code: using Ctrl+C and Ctrl+V across the codebase leads to it becoming verbose and hard to maintain.
  • Increased test suite execution time: as dedicated instances of the required containers are started and stopped for each test class where they are declared.

In this article, we'll be exploring a solution for these problem statements, one that makes use of the lifecycle callback extension available in JUnit 5 called BeforeAllCallback.

This solution would enable us to eliminate the need of writing boilerplate setup code in each of our test classes and significantly reduce the execution time of our test suite by using shared containers that are started only once. These shared containers will still be destroyed once the test suite is completed, similar to the normal setup.

The working code referenced in this article can be found on Github.

The “Problematic” Testcontainers JUnit 5 Setups

To better understand the challenges we've discussed above, let's examine a sample test class:

The above sample test class ProblematicIT makes use of Testcontainers to define and start a Redis container, and configures the corresponding configuration properties using @DynamicPropertySource.

Now imagine having multiple test classes in addition to our class above, each requiring the Redis cache to be up and running for successful test execution.

It becomes apparent that reusing the setup code as well as the container across these classes would improve our testing process significantly.

The Solution: JUnit 5's Lifecycle Callbacks

JUnit 5 introduced lifecycle callbacks, which provide us with the capability to execute logic before or after certain events in the test execution lifecycle. One such callback is the BeforeAllCallback. As the name suggests, it allows us to execute our custom logic once before all the test cases are invoked.

We'll be creating a class RedisCacheInitializer inside our test folder, which implements the BeforeAllCallback interface and fulfills the responsibility of starting the required Redis container along with configuring corresponding configuration properties:

We use an AtomicBoolean flag to track the initial invocation to ensure that the redis container is started only once in our test suite.

Now, we could annotate our test classes with @ExtendWith(RedisCacheInitializer.class) directly, but to further improve the readability of this solution and have a more declarative approach, we simply create a custom marker annotation @InitializeRedisContainer and meta-annotate it with @ExtendWith(RedisCacheInitializer.class):

All that is left now is for us to remove the boilerplate Testcontainers setup from our test classes and leverage our newly created annotation:

And with the above refinement, we've successfully eliminated the need for writing boilerplate setup code in each of our test classes and significantly reduced the execution time of our test suite by using a shared Redis container that is started only once irrespective of the number of classes that are annotated with @InitializeRedisContainer.

Scenario: Multiple Testcontainers Modules

We have already seen how to leverage JUnit 5's BeforeAllCallback extension to create a single shared container instance for our integration tests. However, many applications make use of multiple Testcontainer modules (e.g., Redis, MySQL, and Kafka) in their test suite.

One potential approach could be to create a single class implementing the BeforeAllCallback interface, where we configure and start all the required containers. However, this approach reduces the declarative nature of our solution and introduces unnecessary overheadduring local development.

For instance, when we are trying to slice test our JPA components with @DataJpaTest that only requires the MySQL database, we would still incur the overhead of starting the other defined containers, even though they're not needed for that specific test class.

This issue does not occur when we execute the entire test suite since each container is required to be started anyway, but when we are trying to write and verify individual test cases locally.

Let's consider an application's test suite that requires both MySQL and Redis containers to be operational. Rather than creating a single initializer class that configures and starts both, we'll create two separate classes, each responsible for managing its respective container:

With this modular approach, we retain the declarative nature of our solution and can selectively provide our test classes with the containers they require. Both of the above initializers will be accompanied by a marker annotation, similar to what we've already seen above in this article.

A test class that needs both MySQL and Redis containers, can be annotated with both of our marker annotations:

On the other hand, when slice testing using @DataJpaTest, we'd typically require only the MySQL container to be operational:

I personally recommend this modular approach since it does not affect the maintainability of our test suite, and also aligns with the principles of separation of concerns and single responsibility.

Beyond Containers: Configuring Test Properties with JUnit 5's Lifecycle Callbacks

We might be deviating from the main topic of this article, but it's worth mentioning that the approach of using JUnit 5's BeforeAllCallback extension is not limited to just starting and configuring containers for our integration tests. It can also be used to set up any properties or configurations that are required by our tests.

For instance, let's consider a scenario where we need to initialize a JWT secret key for our application before test suite execution:

We create a SecretKeyInitializer class implementing the BeforeAllCallback interface, which fulfills the responsibility of generating and configuring the test secret key against our custom configuration property.

Similar to what we've seen earlier, we can then create a custom marker annotation @InitializeSecretKey and meta-annotate it with @ExtendWith(SecretKeyInitializer.class). Now, any test class that requires the secret key to be initialized can simply be annotated with it:


With this setup, our test classes become more focused on the actual logic to test, while the initialization of test properties is handled separately and applied in a declarative manner.

A New Alternative: Spring Boot 3.1's @ServiceConnection

Spring Boot 3.1 introduced the @ServiceConnection annotation as part of the new spring-boot-testcontainers test dependency, which simplifies the configuration of containers during integration tests and local development.

By annotating container beans with @ServiceConnection, Spring Boot automatically establishes a connection to the service running inside the container, eliminating the need for manual configuration using @DynamicPropertySource.

Let's take a look at how we can use @ServiceConnection to achieve our goal of reducing boilerplate code and retain the declarative approach of starting containers:

We create a @TestConfiguration class that defines a bean of type MySQLContainer and annotate it with @ServiceConnection.

We can either create a central @TestConfiguration class containing all the required container beans or adhere to the modular and declarative approach we'd discussed above by defining them separately in individual @TestConfiguration classes.

Now, in our test class that requires the MySQL container, we can simply import our MySQLConfiguration class:

With this new feature, we gain another powerful approach to reduce boilerplate code and execution time of our integration tests when using Testcontainers.

However, @ServiceConnection is currently supported for a limited set of container types, and it won't work when dealing with custom images using GenericContainer, or when custom configuration properties are needed to be defined against a started container.

This can be used together with the BeforeAllCallback solution we've discussed throughout this article, giving us the flexibility to choose the most suitable approach for our test suite.

Conclusion

In this article, we explored how to leverage JUnit5's BeforeAllCallback extension to make our test classes more concise and readable. And I believe we've succeeded in achieving that goal.

The solution detailed in this article is not limited to specific container types or use cases, it can be applied to any scenario where we need to start and configure dependent resources or properties for our integration tests.

Do let me know your thoughts or questions in the comment section.

And as always, the complete source code demonstrated throughout this article is available on Github.

Joyful testing,

Hardik Singh Behl
Github | LinkedIn | Twitter

 

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
>