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:
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 | @SpringBootTest class ProblematicIT { private static final int REDIS_PORT = 6379; private static final String REDIS_PASSWORD = RandomString.make(10); private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.2.4"); private static final GenericContainer<?> redisContainer = new GenericContainer<>(REDIS_IMAGE) .withCommand("redis-server", "--requirepass", REDIS_PASSWORD) .withExposedPorts(REDIS_PORT); static { redisContainer.start(); } @DynamicPropertySource static void properties(DynamicPropertyRegistry registry) { registry.add("spring.data.redis.host", redisContainer::getHost); registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(REDIS_PORT)); registry.add("spring.data.redis.password", () -> REDIS_PASSWORD); // ... other custom configuration properties if any } // ... (test cases that require the above dependent resource) } |
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:
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 | public class RedisCacheInitializer implements BeforeAllCallback { private static final AtomicBoolean INITIAL_INVOCATION = new AtomicBoolean(Boolean.TRUE); private final int REDIS_PORT = 6379; private final String REDIS_PASSWORD = RandomString.make(10); private final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.2.4"); private final GenericContainer<?> redisContainer = new GenericContainer<>(REDIS_IMAGE) .withCommand("redis-server", "--requirepass", REDIS_PASSWORD) .withExposedPorts(REDIS_PORT); @Override public void beforeAll(final ExtensionContext context) { if (INITIAL_INVOCATION.getAndSet(Boolean.FALSE)) { redisContainer.start(); addCacheProperties(); } } private void addCacheProperties() { System.setProperty("spring.data.redis.host", redisContainer.getHost()); System.setProperty("spring.data.redis.port", String.valueOf(redisContainer.getMappedPort(REDIS_PORT))); System.setProperty("spring.data.redis.password", REDIS_PASSWORD); } } |
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)
:
1 2 3 4 5 | @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @ExtendWith(RedisCacheInitializer.class) public @interface InitializeRedisContainer { } |
All that is left now is for us to remove the boilerplate Testcontainers setup from our test classes and leverage our newly created annotation:
1 2 3 4 5 6 7 8 9 10 11 | @SpringBootTest @InitializeRedisContainer class UserServiceIT { // test cases } @SpringBootTest @InitializeRedisContainer class AnotherServiceIT { // test cases } |
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:
1 2 3 4 5 6 7 | public class MySQLDatabaseInitializer implements BeforeAllCallback { // ... (implementation details for starting and configuring the MySQL container) } public class RedisCacheInitializer implements BeforeAllCallback { // ... (implementation details for starting and configuring the Redis 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:
1 2 3 4 5 | @SpringBootTest @InitializeMySQLContainer @InitializeRedisContainer class UserServiceIT { // ... (test cases that require both MySQL and Redis) |
On the other hand, when slice testing using @DataJpaTest
, we'd typically require only the MySQL container to be operational:
1 2 3 4 5 | @DataJpaTest @InitializeMySQLContainer class UserRepositoryIT { // ... (test cases that only require MySQL) } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class SecretKeyInitializer implements BeforeAllCallback { private static final AtomicBoolean INITIAL_INVOCATION = new AtomicBoolean(Boolean.TRUE); @Override public void beforeAll(ExtensionContext context) { if (INITIAL_INVOCATION.getAndSet(Boolean.FALSE)) { var secretKey = Jwts.SIG.HS256.key().build().getEncoded(); var base64EncodedSecretKey = Encoders.BASE64.encode(secretKey); System.setProperty("de.rieckpil.jwt.secret-key", base64EncodedSecretKey); } } } |
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:
1 2 3 4 5 | @SpringBootTest @InitializeSecretKey class AuthServiceIT { // test cases that require the secret key } |
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:
1 2 3 4 5 6 7 8 9 10 | @TestConfiguration(proxyBeanMethods = false) class MySQLConfiguration { @Bean @ServiceConnection public MySQLContainer<?> mySQLContainer() { return new MySQLContainer<>("mysql:8"); } } |
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:
1 2 3 4 5 6 | @DataJpaTest @Import(MySQLConfiguration.class) @AutoConfigureTestDatabase(replace = Replace.NONE) class ArticleRepositoryIT { // ... (test cases that require MySQL container) } |
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,