Skip to main content

Testcontainers is a library that provides easy and lightweight APIs for bootstrapping integration tests with real databases and other services wrapped into containers. It’s flexible, reliable, and covers everything your tests need from managing the container lifecycle to advanced networking with ease. Let’s look why it became so popular

Flexibility of configuration

Testcontainers automatically integrates with the Docker environment you have and does all the heavy lifting for you. It locates your available Docker daemon and runs the necessary checks to ensure it can proceed with the tests:

  • does the Docker host have enough disk space,
  • can it pull images,
  • does networking work?

All of these are required by virtually any test that interacts with Testcontainers, and being a responsible citizen Testcontainers ensures test reliability of Docker in your tests. . 

Whenever a test scenario is started, Testcontainers will make sure the requested container images are available to your Docker daemon and pull them if not. Then it’ll configure and start the container and if necessary configure the application in it for a particular test scenario. The flexible programmatic API provided by the library allows using bind mount between the container and the host environment, copying of files into and out of containers, publishing internal TCP ports to the host, running custom commands, building complex network topologies between containers and more. 

You can download or upload files from and to a container, you can access and stream the container logs and even configure the container startup process to be based on them. For example, you can configure the container to wait until a particular message has been received in the container logs before continuing the test execution, thereby ensuring your database is actually ready to accept incoming connections or Kafka has finished configuring itself at startup.

All in all, Testcontainers provides a complete set of APIs for configuring services running in Docker containers programmatically, which tremendously helps in writing integration tests where you routinely want to set up edge case situations and test different configurations.

Integration with the application- and test frameworks

As a library, Testcontainers provides easy-to-use integrations with some of the most well-known Java frameworks such as Spring, Quarkus and Micronaut, as well as integrations with test frameworks such as JUnit4 and JUnit5 as well as Spock.

Even better Testcontainers comes with a rich ecosystem of modules that provide ready-to-use drop-in Java abstractions of some well-known services and databases. his means you can easily start a containerized version of Elasticsearch or Postgres, or Redpanda using an existing abstraction and with no or minimal configuration from your side required.

And for connecting to databases via JDBC specifically, you can make use of a specific JDBC URL, for example by specifying it in the application.properties file of a Spring-Boot application. For example, you can replace the following JDBC URL jdbc:mysql://localhost:3306/databasename with this one jdbc:tc:mysql://localhost:3306/databasename, to automatically start a containerized MySQL instance using Testcontainers once your code tries to establish a JDBC connection.

Container lifecycle

The next pillar of the Testcontainers library is the easy-to-use API for managing the container lifecycle. Whenever you create an instance of GenericContainer, the basic object-oriented abstraction representing a container, you may use the start and stop methods to start and stop the execution of a container manually. Conveniently, the GenericContainer implements the AutoCloseable interface, so it is possible to automatically clean up the container when used in conjunction with a try-with-resources block.

try (var container = new GenericContainer<>("nginx").withExposedPorts(80)) {
    container.start();
    var client = HttpClient.newHttpClient();
    var uri = "http://" + container.getHost() + ":" + container.getFirstMappedPort();
    var request = HttpRequest.newBuilder(URI.create(uri)).GET().build();
    var response = client.send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println(response.body());
}

The JUnit 5 Integration

Let’s look at how it all comes together with an example of the JUnit 5 integration. JUnit 5 provides flexibility on how you can configure test fixtures, gives access to setup, and teardown callbacks on a per-test-case or per class level, and here we look at how you can supercharge your JUnit 5 tests to interact with real technologies running via Testcontainers.

JUnit 5 Fixtures

In JUnit 5 you can use the @BeforeEach, @BeforeAll, @AfterEach, and @AfterAll annotations in order to execute particular code pieces before and after the test cases.. Here’s an example that showcases how they work exactly:

public class JUnitFixturesTest {

    @BeforeAll
    static void beforeAll() {
        System.out.println("BeforeAll");
    }

    @BeforeEach
    void setUp() {
        System.out.println("BeforeEach");
    }

    @Test
    void oneTest() {
        System.out.println("oneTest");
    }

    @Test
    void anotherTest() {
        System.out.println("anotherTest");
    }

    @AfterEach
    void tearDown() {
        System.out.println("AfterEach");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("AfterAll");
    }
}

And here’s how the output of the above test class might look like:

BeforeAll
BeforeEach
oneTest
AfterEach
BeforeEach
anotherTest
AfterEach
AfterAll

So we can clearly see that BeforeAll and AfterAll were invoked only once per test class run, while BeforeEach and AfterEach are invoked before and after each test execution accordingly.

Testcontainers with Fixtures

Naturally, with the Testcontainers you can use these callback methods to manually start and stop the containers conveniently tying lifecycle of the managed services to the lifecycle of your tests. This is great for ensuring proper isolation and making your tests independent from each other.

Per test class containers

For example, we may want to start a database at the beginning of all test cases in the class. We can do this easily by having a static container and using methods marked with BeforeAll and AfterAll:

public class TestcontainersFixturesTest {

    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8");

    @BeforeAll
    static void startDb() {
        mysql.start();
    }
    
    @Test
    void test(){
        // Do the testing here.
    }

    @AfterAll
    static void stopDb() {
        mysql.stop();
    }
}

Or we may want to start some stateful web server before each test case like this:

public class TestcontainersServerFixturesTest {

    NginxContainer<?> nginx = new NginxContainer<>("nginx");

    @BeforeEach
    void startServer() {
        nginx.start();
    }

    @Test
    void test() {
        // Do the testing here.
    }

    @AfterEach
    void stopServer() {
        nginx.stop();
    }
}

The JUnit 5 extension

While fixtures provide all the required APIs and one can build a reusable setup with them, Testcontainers have already built a specific JUnit 5 extension in order to provide even simpler integration with the testing framework.

The extension provides a @Container annotation that should be used to mark fields with a container reference that should be instrumented by the extension. Al fields annotated with @Container need to implement Testcontainers’ Startable interface.

Per test case containers

When an instance field is marked with the annotation, the extension takes care of starting and stopping the container just as if the @BeforeEach and @AfterEach annotations were used, but without any extra hustle. Here is how you can re-write the previous example using @Container annotation:

@Testcontainers
public class TestcontainersServerFixturesTest {
    
    @Container
    NginxContainer<?> nginx = new NginxContainer<>("nginx");

    @Test
    void test() {
        // Do the testing here.
    }
}

Per test class containers

And if we want to achieve container-per-class semantics, we can specify the container field as statics and rewrite our @BeforeAll and @AfterAll example as follows.

@Testcontainers 
public class TestcontainersFixturesTest { 
  @Container 
  static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8");
  @Test
  void test(){
    // Do the testing here.
  }
}

@Testcontainers annotation

You may have noticed the @Testcontainers annotation above the test classes. This is the marker annotation that enabled the TestcontainersExtension for the test class. The extension enables the usage of the @Container annotation but also improves the resources usage while we now only need to look at the classes marked with the @Testcontainers annotation and fields marked with the @Container annotation as it would be inefficient to look at all the classes and all the fields.

Another thing to note is that these annotations fully control the lifecycle of the containers and it’s unnecessary to do that manually in addition to using them. You don’t want to call .start() on a container annotated with @Container, it makes very little sense and the combination of manual and automatic lifecycle control can be tricky to debug later. 

Conclusion

In this article, we looked at the Testcontainers JUnit 5 integration. We reviewed how Testcontainers instruments the container lifecycle as well as the integration with the lifecycle of the test frameworks.

We’ve checked how you can tie the lifecycle of the containers to individual test methods or test classes using @Before/AfterEach and @Before/AfterAll JUnit annotations as well as making use of the Testcontainers JUnit5 extension.

Note, that if you are using the manual lifecycle control methods you don’t really need the JUnit Testcontainers extension annotations like @Testcontainers or @Container. Moreover, their use is likely to confuse the reader of your code and lead to hard-to-debug lifecycle issues.

Oleg Šelajev

Oleg Šelajev is a developer advocate at AtomicJar working on making integration tests with Testcontainers better for everyone in the community. VirtualJUG leader. In 2017 became a Java Champion.