Integration Tests with Testcontainers
To build reliable software, we need to ensure our code works correctly through testing. Unit testing checks the smallest individual pieces of code in isolation, such as a single function or statement. Integration testing ensures that these different pieces, and even external services like databases or message queues work together as expected.
With integration testing we can prevent critical failures in production by detecting and fixing bugs before deployment. Also, when refactoring code that spans multiple components, integration tests ensure that these changes don’t affect existing functionality.
What are Testcontainers? What do they solve and how do they work?
Testcontainers allows us to programmatically control and manage lightweight, disposable instances of common services (databases, message brokers, web browsers, or basically anything else that can run in a Docker container) directly from the test code. Testcontainers provide us an isolated test environment, each test suite gets its own containerized services, eliminating the “works on my machine” problem that often frustrates development teams :)
For example, instead of using in-memory databases like H2 that might behave differently from your production database, Testcontainers spin up actual database instances (PostgreSQL, MySQL, etc.) in Docker containers. This ensures your tests run against the same database engine you use in production, catching database-specific issues that mock databases might miss. With Testcontainers we can test against the exact same versions of databases, message queues, caches, and other services that we use in production.
How It Works Under the Hood
Get Ömer Faruk Karadeniz’s stories in your inbox
Join Medium for free to get updates from this writer.
Testcontainers acts as a smart controller for Docker. When you run your tests, it follows a simple but powerful lifecycle:
- Declaration: In your test code, you declare a requirement for a service by defining a @Container, like PostgreSQL.
- Image Check & Pull: Testcontainers checks if the specified Docker image (e.g., postgres:latest) is available locally. If not, it automatically pulls it from Docker Hub.
- Container Start: It then starts a new container from that image, creating a fresh, isolated instance of your service.
- Dynamic Discovery: Once the container is running, Testcontainers inspects it to find the dynamically assigned port and any generated credentials. This is crucial for avoiding port conflicts on your machine.
- Property Injection: It injects these connection details (the JDBC URL, username, and password) into the Spring application context, allowing your application to connect to the new container.
- Automatic Cleanup: After your tests are complete, Testcontainers automatically stops and removes the container, ensuring a clean environment for the next run.
How to set up Testcontainers for a Spring Boot project?
Assuming we have Docker installed on our machine, we will add Testcontainers dependencies to our maven pom.xml file.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>After adding these dependencies here is an example test code.
Here is a breakdown of what is happening in the code:
- @Testcontainers tells JUnit to manage Testcontainers lifecycles.
- @Container marks a field as a container that Testcontainers should manage
- We specify the Docker image postgres:17 and some default credentials (though Testcontainers can generate them).
- The configureProperties method, annotated with @DynamicPropertySource, is the bridge. It takes the dynamic JDBC url, username, and password from the postgresContainer (once it’s running) and sets the corresponding spring.datasource.* properties. Spring Boot then uses these properties to configure its DataSource.
- @SpringBootTest loads your application context, now configured to talk to the Testcontainer!
When to Use Testcontainers
Perfect for:
- Database-specific feature testing (stored procedures, custom types etc.)
- Multi service integration scenarios
- Testing database migrations and schema changes
- Validating external service integrations
- End-to-end workflow testing
Consider alternatives when:
- You need extremely fast test execution (use unit tests)
- Testing simple CRUD operations (in-memory databases might suffice)
- Resource constraints prevent Docker usage
Conclusion
In the past, integration testing was often difficult. Test environments could break easily, and developers might have to share a single database. This created many issues, especially the famous “it works on my machine” problem, where code works for one person but fails for others.
Testcontainers offers a practical and powerful solution to these old problems. It lets you control the services your application needs (like databases or message queues) directly from your test code. This gives you much better control and ensures that your tests are consistent and run the same way every time.
The result is a testing process that is more trustworthy and easier to manage for both individual developers and automated CI/CD pipelines. By using Testcontainers in your integration tests, you can be confident that your tests use the same kind of services as your production environment, which means you can trust your code to work correctly after deployment.








