I’ve spent years fighting with ‘mock’ databases in my CI pipelines. You know the drill: you spend hours writing complex mocks for PostgreSQL or MongoDB, only for the code to fail in production because the real database behaves differently than your mock. This is why I shifted my entire workflow to Testcontainers.
If you’re looking for a testcontainers tutorial for ci/cd, you’re likely tired of the instability of shared testing databases or the inaccuracy of in-memory replacements like H2. Testcontainers allows you to spin up actual Docker containers for your dependencies—databases, message brokers, or cloud emulators—directly from your test code. This ensures that your unit testing vs integration testing in ci/cd balance is actually meaningful because your integration tests are running against the real deal.
Prerequisites
Before we dive into the implementation, ensure your environment has the following:
- Docker: Installed and running locally, and available on your CI runner (e.g., GitHub Actions, GitLab CI).
- Java 17+ / Node.js 18+ / Python 3.9+: (This tutorial focuses on Java/Spring Boot, but the concepts apply across all languages).
- Maven or Gradle: For dependency management.
- A basic understanding of Docker: You should know how to pull an image and map a port.
Step 1: Adding Testcontainers Dependencies
First, I recommend adding the core Testcontainers library and the specific module for the database you’re using. In my experience, using the junit-jupiter extension makes the lifecycle management much cleaner.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
Step 2: Configuring the Container in Your Tests
Instead of hardcoding a connection string to a local database, we define the container as a static field. I prefer the @Container annotation combined with @Testcontainers to handle the automatic starting and stopping of containers.
@Testcontainers
@SpringBootTest
class UserRepositoryTest {
@Container
static PostgreSQLContainer<;> postgres = new PostgreSQLContainer< >("postgres:15-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void shouldSaveUser() {
// Your test logic here
}
}
The @DynamicPropertySource is the secret sauce here. Since Testcontainers maps the database to a random available port to avoid collisions, we can’t use a static application-test.properties file. This method injects the dynamic port into the Spring context at runtime.
Step 3: CI/CD Pipeline Integration
Running this locally is easy, but the real challenge is the testcontainers tutorial for ci/cd part: making it work in a pipeline. Most modern CI providers support Docker-in-Docker (DinD) or mounting the Docker socket.
GitHub Actions Configuration
In GitHub Actions, Docker is pre-installed. You just need to ensure your workflow has access to the Docker daemon. Here is the configuration I use for my microservices:
jobs:
test:
runs-on: ubuntu-latest
services:
# Note: You don't need to define services here if Testcontainers
# manages the lifecycle, but you must ensure Docker is available.
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Run Tests
run: mvn test
As shown in the image below, the CI runner will now pull the Postgres image, start the container, run the tests, and then kill the container automatically. This prevents the ‘dirty state’ problem where tests fail because a previous run left data in the database.
Pro Tips for Optimization
- Reuse Containers: Starting a container for every test class is slow. Use the
.withReuse(true)configuration or a singleton container pattern to share one instance across all test classes. - Use Alpine Images: Always use
-alpineversions of images (e.g.,postgres:15-alpine). They are significantly smaller, reducing pull time in your CI pipeline by several seconds. - Ryuk is your friend: Testcontainers uses a sidecar container called ‘Ryuk’ to ensure containers are cleaned up even if the JVM crashes. Don’t disable it unless you’re in a very restrictive corporate network.
Troubleshooting Common Issues
If you’re seeing Could not find a valid Docker environment in your CI logs, check the following:
- Docker Socket: Ensure
/var/run/docker.sockis accessible to the user running the tests. - Memory Limits: CI runners often have limited RAM. If your containers are crashing, try reducing the heap size of your JVM to leave more room for Docker.
- Network Latency: If pulling images is taking too long, consider using a local Docker registry mirror in your CI environment.
What’s Next?
Now that you’ve mastered the basics, it’s time to scale. If you’re working in a distributed system, check out our guide on ci/cd testing strategy for microservices to see how to manage multiple dependencies. You can also explore Testcontainers Cloud if you want to offload the Docker overhead from your CI runners entirely.