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:

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.

GitHub Actions logs showing Testcontainers pulling a PostgreSQL image and starting the container during a test run
GitHub Actions logs showing Testcontainers pulling a PostgreSQL image and starting the container during a test run

Pro Tips for Optimization

Troubleshooting Common Issues

If you’re seeing Could not find a valid Docker environment in your CI logs, check the following:

  1. Docker Socket: Ensure /var/run/docker.sock is accessible to the user running the tests.
  2. 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.
  3. 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.