Testcontainers: Real Dependencies in Your Integration Tests

Testcontainers: Real Dependencies in Your Integration Tests

Integration tests that use mocked databases lie. They test your code's interaction with a mock, not with the actual database engine. When your ORM generates slightly wrong SQL, or when PostgreSQL's behavior differs from H2's, or when Redis's Lua scripting has a quirk — your mocked tests pass and your production code fails.

Testcontainers solves this by spinning up real Docker containers for your tests. You get PostgreSQL, Redis, Kafka, Elasticsearch — the actual services — running locally, managed automatically.

What Testcontainers Is

Testcontainers is a library (available for Java, Go, Node.js, Python, Rust, and more) that provides a programmatic API to start Docker containers before tests and stop them after. Each container runs a real service — not a mock, not an in-memory substitute.

// Java — start a real PostgreSQL
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
    .withDatabaseName("testdb")
    .withUsername("test")
    .withPassword("test");

postgres.start();
// Now connect to postgres.getJdbcUrl() — it's a real PostgreSQL

The containers are ephemeral: they start before the test, run during it, and are destroyed after. Each test run gets a clean state.

Prerequisites

  • Docker installed and running
  • Your language's Testcontainers library
# Verify Docker is available
docker ps

Testcontainers uses the Docker socket to manage containers. On CI, you need a runner that supports Docker-in-Docker or has Docker available (most GitHub Actions runners do).

Core Concepts

Container: A Docker container wrapping a specific image (postgres:16, redis:7, etc.)

Module: A pre-configured container for a common service (PostgreSQLContainer, RedisContainer, KafkaContainer). Modules handle port mapping, health checks, and connection string generation automatically.

Lifecycle:

  • start() — pulls the image (first time) and starts the container
  • Tests run — container is accessible at a dynamic port
  • stop() — container is destroyed, all data lost

Port mapping: Testcontainers maps the container's internal port to a random host port. Always use the library's methods to get the actual port — never hardcode.

Available Modules

Most Testcontainers implementations include modules for:

  • Databases: PostgreSQL, MySQL, MariaDB, MongoDB, CockroachDB, Oracle
  • Cache: Redis, Memcached
  • Message brokers: Kafka, RabbitMQ, ActiveMQ, Pulsar
  • Search: Elasticsearch, OpenSearch, Solr
  • Cloud services: LocalStack (AWS), Azurite (Azure Blob)
  • Generic: Any Docker image via GenericContainer

Generic Containers

For services without a dedicated module, use GenericContainer:

// Java
GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
    .withExposedPorts(6379)
    .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1));

redis.start();
int port = redis.getMappedPort(6379);
String host = redis.getHost();
// Node.js
import { GenericContainer } from 'testcontainers';

const container = await new GenericContainer('redis:7-alpine')
  .withExposedPorts(6379)
  .start();

const port = container.getMappedPort(6379);
const host = container.getHost();

Wait Strategies

Testcontainers needs to know when a container is ready. Built-in wait strategies:

// Wait for a specific log message
.waitingFor(Wait.forLogMessage(".*database system is ready.*", 1))

// Wait for a port to be open
.waitingFor(Wait.forListeningPort())

// Wait for an HTTP endpoint to return 200
.waitingFor(Wait.forHttp("/health").forStatusCode(200))

// Wait for a specific exit code (for init containers)
.waitingFor(Wait.forSuccessfulCommand("redis-cli ping"))

Container Lifecycle Management

Start/stop per test — cleanest, slowest:

@BeforeEach
void setUp() { container.start(); }

@AfterEach
void tearDown() { container.stop(); }

Start once per test class — faster, requires cleaning data between tests:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

Singleton pattern — share across the entire test suite:

public class DatabaseSingleton {
    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16");
        POSTGRES.start();
        Runtime.getRuntime().addShutdownHook(new Thread(POSTGRES::stop));
    }
}

The singleton is the fastest option but requires tests to clean up after themselves (or use transactions).

Network Communication Between Containers

When multiple containers need to talk to each other:

Network network = Network.newNetwork();

GenericContainer<?> backend = new GenericContainer<>("my-api:latest")
    .withNetwork(network)
    .withNetworkAliases("api");

GenericContainer<?> db = new PostgreSQLContainer<>("postgres:16")
    .withNetwork(network)
    .withNetworkAliases("db");

// backend connects to db via "db:5432" — the network alias

Reuse Mode (Development Speed)

Starting containers takes time. During development, enable container reuse to keep containers alive between test runs:

PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
    .withReuse(true);
# Enable in ~/.testcontainers.properties
testcontainers.reuse.enable=true

With reuse enabled, the second test run skips container startup and reuses the running container. Data persists between runs — useful for development, disable for CI.

Performance

Container startup time is the main cost. Typical startup times:

Service Startup
PostgreSQL 3–5 seconds
Redis 1–2 seconds
Kafka 8–15 seconds
Elasticsearch 15–30 seconds

To minimize impact:

  1. Use the singleton pattern to start once per test suite
  2. Use withReuse(true) during development
  3. Pre-pull images in CI (docker pull postgres:16 before running tests)
  4. Use lightweight images (alpine variants where available)

Summary

Testcontainers makes integration tests honest. Instead of mocking the database:

  1. Start a real database in Docker
  2. Run your tests against it
  3. Destroy the container when done

The tests take longer than unit tests but catch real bugs — SQL dialect differences, constraint violations, transaction behavior, connection pooling issues — that mocks silently ignore.

Use Testcontainers for integration tests. Keep unit tests for pure logic. The split gives you both speed and confidence.

Read more

Start now free