Testcontainers — Integration Testing with Docker

3 minute read

Unit tests mock dependencies. But sometimes you need to test against a real database or real message broker. Testcontainers spins up Docker containers for your tests and tears them down automatically.


Maven Dependencies

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.20.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.0</version>
    <scope>test</scope>
</dependency>
<!-- Add module for your database/service -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.20.0</version>
    <scope>test</scope>
</dependency>

Prerequisites

  • Docker must be running on the machine where tests execute.
  • Works with Docker Desktop, Colima, Rancher Desktop, Podman (with config).

PostgreSQL Integration Test

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.sql.*;

import static org.junit.jupiter.api.Assertions.*;

@Testcontainers
class UserRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @Test
    void shouldInsertAndRetrieveUser() throws SQLException {
        try (Connection conn = DriverManager.getConnection(
                postgres.getJdbcUrl(),
                postgres.getUsername(),
                postgres.getPassword())) {

            // Setup
            conn.createStatement().execute(
                "CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100))");
            conn.createStatement().execute(
                "INSERT INTO users (name) VALUES ('Alice')");

            // Query
            ResultSet rs = conn.createStatement().executeQuery(
                "SELECT name FROM users WHERE id = 1");
            assertTrue(rs.next());
            assertEquals("Alice", rs.getString("name"));
        }
    }
}

How It Works

1. @Testcontainers annotation activates the extension
2. @Container fields are started before tests
3. Docker pulls the image (first time) and starts a container
4. Container gets a random port (avoids conflicts)
5. Tests run against the real service
6. Container is stopped and removed after tests

Spring Boot Integration

@SpringBootTest
@Testcontainers
class UserServiceIT {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-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);
    }

    @Autowired
    private UserService userService;

    @Test
    void shouldCreateUser() {
        User user = userService.createUser("Alice", "alice@test.com");
        assertNotNull(user.getId());

        User found = userService.findById(user.getId());
        assertEquals("Alice", found.getName());
    }
}

@DynamicPropertySource injects the container’s random port/URL into Spring’s configuration.


Redis Container

@Testcontainers
class CacheServiceIT {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @Test
    void shouldCacheAndRetrieve() {
        String host = redis.getHost();
        int port = redis.getFirstMappedPort();

        // Use Jedis, Lettuce, or any Redis client
        try (Jedis jedis = new Jedis(host, port)) {
            jedis.set("key", "value");
            assertEquals("value", jedis.get("key"));
        }
    }
}

Kafka Container

@Testcontainers
class KafkaProducerIT {

    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));

    @Test
    void shouldProduceAndConsumeMessage() {
        String bootstrapServers = kafka.getBootstrapServers();

        // Configure producer/consumer with bootstrapServers
        // Send and receive messages...
    }
}

Shared Containers (Singleton Pattern)

Starting a container per test class is slow. Share across tests:

public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb");
        POSTGRES.start();  // started once, reused across all test classes
    }

    @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);
    }
}

class UserServiceIT extends AbstractIntegrationTest {
    // Uses the shared PostgreSQL container
}

class OrderServiceIT extends AbstractIntegrationTest {
    // Same container — fast!
}

Docker Compose Support

@Testcontainers
class FullStackIT {

    @Container
    static DockerComposeContainer<?> environment =
        new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
            .withExposedService("postgres", 5432)
            .withExposedService("redis", 6379);

    @Test
    void shouldWorkWithMultipleServices() {
        String pgHost = environment.getServiceHost("postgres", 5432);
        int pgPort = environment.getServicePort("postgres", 5432);
        // ...
    }
}

CI/CD: Docker-in-Docker

GitHub Actions

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      - name: Run integration tests
        run: mvn verify -Pfailsafe
        # Docker is available by default on ubuntu-latest

Available Modules

Module Image
postgresql PostgreSQL
mysql MySQL
mongodb MongoDB
kafka Apache Kafka
elasticsearch Elasticsearch
localstack AWS services (S3, SQS, etc.)
rabbitmq RabbitMQ
mssqlserver SQL Server
redis (via GenericContainer) Redis

Common Mistakes

Mistake Fix
Docker not running Start Docker Desktop before tests
Slow tests (container per class) Use singleton pattern for shared containers
Hardcoded ports Use getFirstMappedPort() — ports are random
Image not found Check Docker image name and tag

Key Takeaways

  1. Testcontainers = real services in Docker for integration tests.
  2. @Testcontainers + @Container = automatic lifecycle management.
  3. Use @DynamicPropertySource for Spring Boot integration.
  4. Singleton pattern shares containers across test classes for speed.
  5. Docker must be available — works in GitHub Actions out of the box.

What’s Next?

Next: Testing Best Practices — naming conventions, anti-patterns, test organization, and how to write tests that last.

Updated: