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 PostgreSQLThe 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 psTestcontainers 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 aliasReuse 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=trueWith 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:
- Use the singleton pattern to start once per test suite
- Use
withReuse(true)during development - Pre-pull images in CI (
docker pull postgres:16before running tests) - Use lightweight images (alpine variants where available)
Summary
Testcontainers makes integration tests honest. Instead of mocking the database:
- Start a real database in Docker
- Run your tests against it
- 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.