Testing is a critical aspect of software development that ensures the reliability, performance, and security of applications. By verifying that code behaves as expected, testing builds confidence in software’s functionality and improves overall product quality. Whether it’s unit testing, integration testing, or end to end testing, each level serves to catch different types of issues – ensuring a robust and stable final product. In essence, testing is crucial for delivering dependable software that meets user and business requirements.
What’s more, testing helps identify defects early in the development process, which reduces the risk of costly errors later.
Different types of tests are solved by different tools and frameworks. One of them is Testcontainers.
What is Testcontainers?
Testcontainers is a testing library that provides easy and lightweight APIs for bootstrapping integration tests with real services wrapped and launched in Docker containers. Generally, think of it as a tool that enables you to run all the services you need to create production-like environments without mocks or in-memory services, and launch your integration tests locally. All you need is a working Docker environment on your machine.
What problems do Testcontainers solve?
To understand the necessity behind creating a Testcontainers library, we must remind ourselves of the problems related to integration testing.
Historically, integration testing has been considered difficult. I believe the main reasons are infrastructural difficulties and a lack of specialized tools.
Infrastructural problems
Maintaining the whole infrastructure for testing purposes was challenging due to several reasons:
- before running a test suit, you had to ensure that the infrastructure is up and running and data is pre-configured in a specific desired state;
- running multiple build pipelines in parallel, when one test execution might interfere with other tests;
- errors in integration tests often came from interactions between multiple systems, so without clear log messages it was not easy to pinpoint the issue and because of that it was harder to reproduce the issue on local environment;
- it was necessary to wait some time to get test results, meaning the feedback loop was slow.
Other problems
Due to the problems mentioned above, people started to lean towards in-memory solutions. However:
- in-memory databases like H2 does not support all the features of more advanced SQL providers like PostgreSQL or Oracle;
- there are differences between H2 and other database providers in the SQL dialect;
To solve these issues, people stopped using advanced PostgreSQL or Oracle features and started to keep two implementations: one for the target solution and one for testing. Both have their own obvious flaws.
Testcontainers to the rescue!
Testcontainers solves all the problems mentioned earlier by using Docker to spin up disposable containers for databases, message brokers and all other required services.
There’s no need to push any changes and wait for CI to run your integration tests. It all happens automatically before integration test execution on your local machine and is much faster than regular deployments to testing environments. What’s also important is that the code for defining the Testcontainers infrastructure resides directly next to the actual test code, so a developer does not have to switch tools or context. It is all in IDE. Everything can be run with a single click, just like unit tests.
Every time new containers set up is created, you need to make sure that the databases and other services are in specific, desired state, so no other tests interfere. There will be no data conflict issues, even when multiple build pipelines run in parallel because each pipeline runs with an isolated set of services.
Running everything on a local machine makes it possible to easily debug the code and pinpoint the issue. Using the exact services as the production environment ensures that there are no differences in behavior and all the features of the production service can be used – without worrying about compatibility.
After test execution, Testcontainers take care of cleaning up the containers automatically.
As you see with Testcontainers, setup is automatic, and the feedback loop is faster, as containers are fairly quick to start and tear down. This makes integration testing reliable, reproducible, and much closer to real-world scenarios without the usual infrastructure overhead. It makes developers’ and testers’ lives much easier.
Testcontainers can support multiple languages:
- Java
- Go
- .NET
- Node.js
- Python
- Rust
- Haskell
- Ruby
Within the Java ecosystem there are preconfigured containers for services like:
- databases, i.e:
- PostgreSQL;
- MySQL;
- Oracle;
- MongoDB;
- Neo4j;
- Cassandra;
message brokers, i.e:
- Kafka;
- PubSub;
- RabbitMQ;
other services, i.e:
- MinIO;
- Mockserver;
- Grafana;
- Elasticsearch;
- Toxiproxy.
However, remember that literally anything that can be run in a docker container can be run using Testcontainers library.
Testcontainers workflow
A typical workflow contains three phases:
Before tests:
- starting up required services (databases, message brokers, storages, etc.) in a docker environment using Testcontainers API;
- configuring the application to be able to access containerized services (typically setting up urls, usernames, passwords);
During tests:
- running all the integration tests using containerized services;
After tests:
- using Testcontainers (if not reconfigured) to take care of destroying those containers, irrespective of the test results.
Example use case
Testcontainers dependencies
The basic configuration of Testcontainers is fairly simple. As usual, everything starts in the pom.xml.
The following dependency adds the basic GenericContainer to your project.
1. <dependency>
2. <groupid>org.testcontainers</groupid>
3. <artifactid>testcontainers</artifactid>
4. <version>1.20.1</version>
5. <scope>test</scope>
6. </dependency>
To allow smooth usage of the containers with Java annotation support, a specific to a test framework dependency should also be added.
For Junit5 support add:
1. <dependency>
2. <groupid>org.testcontainers</groupid>
3. <artifactid>junit-jupiter</artifactid>
4. <version>1.20.1</version>
5. <scope>test</scope>
6. </dependency>
for Spock:
1. <dependency>
2. <groupid>org.testcontainers</groupid>
3. <artifactid>spock</artifactid>
4. <version>1.20.1</version>
5. <scope>test</scope>
6. </dependency>
This enables the use of specific annotations, like class level @Testcontainers which, according to documentation: “is a JUnit Jupiter extension to activate automatic startup and stop of containers used in a test case”.
Better dependency management can be achieved using Testcontainers BOM (bill of materials):
1. <dependencymanagement>
2. <dependencies>
3. <dependency>
4. <groupid>org.testcontainers</groupid>
5. <artifactid>testcontainers-bom</artifactid>
6. <version>1.20.1</version>
7. <type>pom</type>
8. <scope>import</scope>
9. </dependency>
10. </dependencies>
11. </dependencymanagement>
After adding a dependencyManagement tag with library BOM, it is no longer necessary to specify versions of the dependencies.
1. <dependency>
2. <groupid>org.testcontainers</groupid>
3. <artifactid>junit-jupiter</artifactid>
4. <scope>test</scope>
5. </dependency>
As mentioned earlier, Testcontainers provides different specialized containers for the most popular tools and services. The whole list is available here in modules section.
By adding a Postgres module, a PostgreSQLContainer will be available in test classes.
1. <dependency>
2. <groupid>org.testcontainers</groupid>
3. <artifactid>postgresql</artifactid>
4. <scope>test</scope>
5. </dependency>
Simple test class
Below you can find an example of a test class using PostgreSQLContainer.
The class itself is annotated with:
- @SpringBootTest annotation which starts the application context on a randomly chosen port;
- @Testcontainers annotation which combined with fields annotated with @Container mark containers that will be managed by extension automatically;
- @Slf4j for logging.
In the next lines we see POSTGRES field of type PostgreSQLContainer<?> which is a dedicated type to PostgreSQL database provided by the proper Testcontainers postgresql module. Method withInitScript is a handy method which allows developers to specify an initialization script, while withLogConsumer provides functionality to fetch and display logs from the container comfortably in the IDE console.
What is extremely important is the static setup method with DynamicPropertyRegistry object passed as an argument. @DynamicPropertySource is an annotaion introduced by Spring 5.2.5 to facilitate adding properties with dynamic values. In this example spring.datasource.url, spring.datasource.username, spring.datasource.password can easily be fetched from the running container and assigned, allowing Spring to connect to the database.
Spring in version 3.1.0 improved Testcontainers support by adding @ServiceConnection annotation which is “used to indicate that a field or method is a ContainerConnectionSource which provides a service that can be connected to”. In practice it means that in typical cases, there is no need to specify a setup method annotated with @DynamicPropertySource –just annotate the container field:
This way, frameworks take care of everything and setup the required connection. All the supported service connections are listed here.
Testcontainers for development
In addition to enhanced Testcontainers support, Spring 3.1.0 introduced another valuable feature: using Testcontainers during development. To enable this, two components are needed: a separate class with a main method and a configuration class where all containers are defined as Spring beans.
It’s a straightforward but highly practical feature. The setup shown above lets the application from the TestTestcontainersApplication class start with all the necessary containers in just one click. Instead of relying on tools like Docker Compose controlled from CLI, it is possible to have the entire application and its dependencies up and running within seconds.
Practical notes
Testcontainers are started on a random port (unless otherwise configured using .withExposedPorts() method), which is why having the option to specify the connection details dynamically is so important (@DynamicPropertySource and @ServiceConnection).
It’s highly recommended to go through all the methods exposed by the containers to get familiar with the possibilities provided by the library and specific modules.
If Testcontainers for development are being used, all the changes made on a database will disappear after application (and containers) relaunch.
It’s possible to configure reusable containers for tests (not recommended) and development – it is very annoying to change the port in the database query tool every single time the application is relaunched or lose all the changes that were made to database. It can be achieved using .withReuse(true) and some additional configuration. More information here. It is still an experimental feature.
In a classic approach, Testcontainers requires a local Docker environment. However, the creators of the library provided a Testcontainers Cloud service, which enables the running of tests in the cloud. Read more here.
Summary
Testcontainers is a valuable tool for Java developers that simplifies integration testing by leveraging Docker containers. It addresses common challenges in testing environments, such as infrastructure setup, slow feedback loops, and inconsistencies between testing and production environments. By running services in isolated, disposable containers, Testcontainers enables tests to be run locally with real services instead of in-memory or mocked versions, ensuring more accurate and reproducible results.
In addition to testing, Testcontainers is highly useful for development. Developers can configure their containers as Spring beans, allowing them to run applications with all dependencies through a simple setup. This eliminates the need for external tools like Docker Compose, making it easy to spin up environments directly from the IDE. This streamlined process accelerates development and ensures consistency between development and testing environments, saving time and reducing errors.
Software Mind’s engineers are experienced with a wide range of software development tools, technologies and tactics. Get in touch and find out how they can support your projects and growth goals by filling out this form.
About the authorJacek Chmiel
Software Engineer
A former structural engineer who combines a unique perspective with strong analytical skills and a problem-solving mindset to software development. With several years of experience in Java development, he consistently delivers scalable, testable solutions tailored to customer needs. Dedicated to staying at the forefront of advancements in the Java ecosystem, Jacek avidly keeps up to date with new technologies and methodologies