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.