Efficient test startup: JUnit + Micronaut + Testcontainers
Some time ago I was wondering how to speed up the execution time of integration tests in my application. I was already using Micronaut framework which gave me a huge boost thanks to incredibly fast startup. But I struggled with Docker containers, started through Testcontainers library. In JUnit, they had to be restarted between test classes. As the number of those classes grew, those restarts became more and more painful. I started looking for a way to start them exactly once. In this article I would like to show you a simple, yet not so obvious solution for an efficient test startup.
tl;dr;
The trick is to hook directly into JUnit Platform which allows us listening for test plan start and completion.
What we will learn
In this article, we will learn, how to:
- create and install JUnit Platform listeners
- inject dynamic configuration into Micronaut applications
- use Service Locator in Java
- speed up your integration tests through the combination of the above
Note: if you are using Kotlin, check out also a variant of this article for this language: Efficient test startup: Kotest + Micronaut + Testcontainers!
Example application
This article shows only the actual solution in the interest of space. However, for experiments and checking out the solution in practice, we may need a sample application with tests. You can download a complete, working example from Github: zone84-examples/efficient-test-startup.
The example application contains four endpoints for creating and reading articles and products, and some tests for them. It is written in Micronaut 3.3 and Java 17, and uses MongoDB for storing the data, so that we have some usable Docker container to work with.
Overview of JUnit
Like we said, JUnit allows us installing @BeforeAll
/ @AfterAll
hooks only for individual test classes. There is no hook for starting something before and after all tests. But in fact, have we checked that in detail? Let’s take a closer look at JUnit architecture.
JUnit 5 is not a monolithic project. Instead, it comprises two subprojects with different responsibilities:
- JUnit Jupiter: this is the actual framework that we use for writing tests
- JUnit Platform: this is the foundation for building test frameworks
This separation allows building new test frameworks on top of JUnit. The platform provides their authors a lot of useful features, such as full integration with IDE-s for free. As developers, we can mix multiple frameworks in one project, and they can gracefully co-exist. In fact, there are already several frameworks based on JUnit Platform, such as Kotest in Kotlin ecosystem.
The mentioned annotations belong to JUnit Jupiter which does not allow us doing what we want. But it turns out that JUnit Platform does. Although it may sound scary to add such a low-level hook, we will soon see that the proposed solution is in fact very short and simple. To use platform API-s, we need one more compile-time dependency in our project:
testImplementation("org.junit.platform:junit-platform-launcher")
Of course, we also need Testcontainers with a MongoDB module:
testImplementation("org.testcontainers:testcontainers:1.16.2")
testImplementation("org.testcontainers:mongodb:1.16.2")
Efficient test startup solution
Now we are ready to implement the actual solution. Firstly, let’s create a simple class called Environment
. It will be responsible for starting and stopping Testcontainers:
public class Environment {
private static final Logger log = LoggerFactory.getLogger(Environment.class);
private final MongoDBContainer mongoDb
= new MongoDBContainer(DockerImageName.parse("mongo:5.0.5"));
public int getMongoDbPort() {
return mongoDb.getFirstMappedPort();
}
public void startServices() {
mongoDb.start();
log.info("Started MongoDB service on mapped port " + getMongoDbPort());
}
public void stopServices() {
log.info("Stopping all services...");
mongoDb.stop();
log.info("Stopped all services");
}
}
As we see, launching Docker containers is very easy with Testcontainers. The library has a useful, but tricky feature called “port randomization”. Basically, every time we launch tests, MongoDB starts up on a different, random port. It helps isolating test launches in the build system, but requires passing the generated port number to the tested application (eventually, it needs to know where to connect to!). For this, we expose getMongoDbPort()
method to read this port number later.
JUnit Platform listener
Now we can integrate Environment
class with JUnit Platform. For this, we will create a test execution listener:
public class EnvironmentStartupListener implements TestExecutionListener {
private static volatile Environment environment;
@Override
public void testPlanExecutionStarted(TestPlan testPlan) {
environment = new Environment();
environment.startServices();
}
@Override
public void testPlanExecutionFinished(TestPlan testPlan) {
environment.stopServices();
}
}
The two useful methods of TestExecutionListener
are testPlanExecutionStarted()
and testPlanExecutionFinished()
. They get called before and after all tests scheduled to run. It can be just one test, it can be a test class, or it can be the entire project. This is exactly what we were looking for.
As we can see, the solution is almost trivial: it’s about knowing what to use. Hooking directly into JUnit Platform has two more advantages:
- it works for any test framework based on JUnit Platform,
- it works even if we mix two frameworks in a single project (for example, we are migrating from one to another)
Registering the listener
The next step is telling JUnit about our listener. To do it, we must use Service Locator mechanism available in Java standard library. Service Locator finds all implementations of the given interface present on classpath, and returns their instances. In this way it is similar to dependency injection containers, just… it does not do any dependency injection. You cannot inject any dependencies into those implementations.
To register our listener, we need to create a text file in /src/test/resources/META-INF/services
directory. The file must be named from the interface we want to extend – org.junit.platform.launcher.TestExecutionListener
in our case. The file contains a single line, a reference to our implementation:
tech.zone84.examples.efficientteststartup.environment.EnvironmentStartupListener
Useful hint
Java module system is integrated with Service Locator. If you are using it in your project, you don’t have to create any text files. Instead, you can register your listener directly in module-info.java
.
Injecting configuration into Micronaut
Launching Docker containers exactly once is already completed. However, our application still doesn’t know where to connect due to the port randomization mentioned earlier. Now we need to take a closer look at Micronaut, and find a way to pass the dynamically generated port number to the configuration.
Micronaut offers a way to build a dynamic configuration by creating custom PropertySourceLoader
implementations. Inside, we can generate some properties and their values and return them as a plain Java map. However, we cannot register the loader as a bean. It may be surprising, but there’s a good reason for this. Micronaut creates all beans only when the entire configuration is already loaded, whereas the loaders must do their job earlier to build this configuration. So how we can register one? Again, using Service Locator! So let’s add PropertySourceLoader
interface to our already existing class EnvironmentStartupListener
:
public class EnvironmentStartupListener
implements TestExecutionListener, PropertySourceLoader
{
private static volatile Environment environment;
// previously created methods go here
@Override
public Optional<PropertySource> load(
String resourceName, ResourceLoader resourceLoader
) {
if (resourceName.equals("application")) {
return Optional.of(
PropertySource.of(
Map.of(
"mongodb.uri",
"mongodb://localhost:" + environment.getMongoDbPort()
)
)
);
}
return Optional.empty();
}
@Override
public Optional<PropertySource> loadEnv(
String resourceName, ResourceLoader resourceLoader,
ActiveEnvironment activeEnvironment
) {
return Optional.empty();
}
@Override
public Map<String, Object> read(String name, InputStream input)
throws IOException
{
return null;
}
}
Registration in service locator: create another file io.micronaut.context.env.PropertySourceLoader
in /src/test/resources/META-INF/services
directory with the following single line:
tech.zone84.examples.efficientteststartup.environment.EnvironmentStartupListener
Our loader does a very simple thing: when asked for configuration, it generates the value of mongodb.uri property, and inserts the port number obtained from Testcontainers into the database URI. Now our application running in integration tests can connect to MongoDB, and basically… we are done!
Useful hint
A similar configuration injection is also possible in Spring Framework.
Why static volatile?
We might wonder why environment
field in EnvironmentStartupListener
class is static volatile. It’s all about how Service Locator works. Like we said earlier, it’s not a dependency injection container. We implemented two different interfaces on the same class, and registered this class as an implementation of two different services. If we follow the execution with a debugger, we notice that Service Locator creates two distinct instances of our listener: one for JUnit, one for Micronaut. Without dependency injection and without a common instance, the only way to share some state is using static variables. I added volatile, because we cannot guarantee that JUnit and Micronaut will run their methods in the same thread. When two threads access the same field without synchronization, they may end up reading inconsistent state due to how CPU caches work. volatile ensures us that every thread always reads the most recent (and up-to-date value) from the memory.
Warning
Normally, I strongly recommend avoiding mutable static fields in our applications. This is one of the very few exceptions where this golden rule can be broken: test code + low-level internals + plumbing code between two frameworks.
Summary
The main challenge in this solution is figuring out that the mentioned hooks exist. Although user guides of both frameworks mention them, they do it in sections for advanced users. Similarly, Service Locator is a lesser known mechanism of Java. Once we know them, the working solution is just a few lines of code and some configuration. I found it very simple and useful, especially that it works with any test framework. I hope that it will also work for you!
Remember to check out also the variant of this article for Kotlin and Kotest framework!
Sample code
Find a complete example on GitHub: zone84-examples/efficient-test-startup