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:

  1. it works for any test framework based on JUnit Platform,
  2. 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

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments