Testcontainers: Integration Testing with Real Docker Containers
Mocks lie. They model your assumptions about a dependency, not the dependency itself. When a PostgreSQL query behaves differently across versions, when a Redis EXPIRE command has subtle timing behavior, or when Kafka consumer group rebalancing breaks your processing logic — mocks won't catch it. Testcontainers will.
Testcontainers is a library that spins up real Docker containers for your tests, then tears them down when the test is done. Your test talks to an actual PostgreSQL instance, an actual Redis node, an actual Kafka broker. The confidence gap between "passes locally" and "fails in production" shrinks dramatically.
Why Testcontainers Beats Mocking
The argument for mocks is speed and isolation. The argument against them is fidelity. When you mock a database, you're testing your mock — not PostgreSQL's constraint enforcement, transaction isolation, or index behavior. When production uses a real broker and your test uses an in-memory stub, you're flying blind.
Testcontainers gives you isolation (each test run gets its own container, on its own port) without sacrificing fidelity (it's the real software). Startup overhead is the trade-off: a PostgreSQL container takes 2-5 seconds to be ready. That's acceptable for integration tests, which you don't run on every keystroke anyway.
Java: PostgreSQL with Testcontainers
Add the dependency:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>A basic repository test:
@Testcontainers
@SpringBootTest
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders_test")
.withUsername("test")
.withPassword("test");
@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
OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrder() {
Order order = new Order("customer-42", BigDecimal.valueOf(99.99));
orderRepository.save(order);
Optional<Order> found = orderRepository.findById(order.getId());
assertThat(found).isPresent();
assertThat(found.get().getCustomerId()).isEqualTo("customer-42");
}
@Test
void shouldEnforceUniqueConstraint() {
Order order1 = new Order("customer-1", BigDecimal.TEN);
Order order2 = new Order("customer-1", BigDecimal.TEN);
order1.setExternalId("SAME-REF");
order2.setExternalId("SAME-REF");
orderRepository.save(order1);
assertThatThrownBy(() -> orderRepository.save(order2))
.isInstanceOf(DataIntegrityViolationException.class);
}
}The static container declaration means the container starts once per test class and is reused across test methods — significantly faster than one container per test.
Java: Kafka with Testcontainers
@Testcontainers
class OrderEventConsumerTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
private KafkaConsumer<String, String> consumer;
@BeforeEach
void setUp() {
Map<String, Object> consumerProps = new HashMap<>();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group-" + UUID.randomUUID());
consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(List.of("order-events"));
}
@Test
void shouldConsumeOrderCreatedEvent() throws Exception {
KafkaProducer<String, String> producer = buildProducer(kafka.getBootstrapServers());
producer.send(new ProducerRecord<>("order-events", "order-1", "{\"type\":\"ORDER_CREATED\"}"));
producer.flush();
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));
assertThat(records.count()).isEqualTo(1);
assertThat(records.iterator().next().value()).contains("ORDER_CREATED");
}
}Node.js: PostgreSQL and Redis
Testcontainers has a first-class JavaScript client:
npm install --save-dev testcontainersPostgreSQL example with pg:
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
describe("UserRepository", () => {
let container: StartedPostgreSqlContainer;
let pool: Pool;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16-alpine")
.withDatabase("test_db")
.withUsername("test_user")
.withPassword("test_pass")
.start();
pool = new Pool({ connectionString: container.getConnectionUri() });
await pool.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
}, 30_000);
afterAll(async () => {
await pool.end();
await container.stop();
});
it("should insert and retrieve a user", async () => {
await pool.query("INSERT INTO users (email) VALUES ($1)", ["alice@example.com"]);
const result = await pool.query("SELECT email FROM users WHERE email = $1", ["alice@example.com"]);
expect(result.rows[0].email).toBe("alice@example.com");
});
it("should reject duplicate emails", async () => {
await pool.query("INSERT INTO users (email) VALUES ($1)", ["bob@example.com"]);
await expect(
pool.query("INSERT INTO users (email) VALUES ($1)", ["bob@example.com"])
).rejects.toThrow(/unique constraint/i);
});
});Redis with ioredis:
import { RedisContainer } from "@testcontainers/redis";
import Redis from "ioredis";
describe("SessionStore", () => {
let container: StartedRedisContainer;
let redis: Redis;
beforeAll(async () => {
container = await new RedisContainer("redis:7-alpine").start();
redis = new Redis(container.getConnectionUrl());
}, 20_000);
afterAll(async () => {
await redis.quit();
await container.stop();
});
it("should expire session keys", async () => {
await redis.set("session:abc123", JSON.stringify({ userId: 42 }), "EX", 1);
const before = await redis.get("session:abc123");
expect(JSON.parse(before!).userId).toBe(42);
await new Promise((r) => setTimeout(r, 1100));
const after = await redis.get("session:abc123");
expect(after).toBeNull();
});
});GenericContainer for Custom Images
When there's no pre-built module for your dependency, GenericContainer covers it:
import { GenericContainer, Wait } from "testcontainers";
const container = await new GenericContainer("my-company/legacy-service:2.1.0")
.withExposedPorts(8080)
.withEnvironment({ APP_ENV: "test", DB_URL: postgresUrl })
.withWaitStrategy(Wait.forHttp("/health", 8080).forStatusCode(200))
.withStartupTimeout(60_000)
.start();
const serviceUrl = `http://${container.getHost()}:${container.getMappedPort(8080)}`;The Wait strategies are critical. Containers report "started" before the process inside is ready to accept connections. Use Wait.forHttp() for HTTP services, Wait.forLogMessage() for services that log a ready indicator, or Wait.forListeningPorts() for raw TCP readiness.
Container Networking
When containers need to talk to each other (e.g., your app container connecting to a database container), use a shared Docker network:
import { Network } from "testcontainers";
const network = await new Network().start();
const postgres = await new PostgreSqlContainer()
.withNetwork(network)
.withNetworkAliases("db")
.start();
const app = await new GenericContainer("my-app:latest")
.withNetwork(network)
.withEnvironment({ DATABASE_URL: "postgres://test:test@db:5432/test_db" })
.withExposedPorts(3000)
.start();Inside the shared network, containers address each other by alias — db resolves to the PostgreSQL container's internal IP. No port mapping gymnastics needed.
CI Integration
Testcontainers requires Docker. In GitHub Actions, Docker is available on ubuntu-latest runners by default:
name: Integration Tests
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- name: Run integration tests
run: mvn test -pl integration-tests
env:
TESTCONTAINERS_RYUK_DISABLED: falseFor Node.js:
- name: Run integration tests
run: npx jest --testPathPattern=integration
env:
TESTCONTAINERS_RYUK_DISABLED: false
NODE_OPTIONS: "--experimental-vm-modules"Ryuk is the Testcontainers resource reaper — it cleans up dangling containers if your test process crashes. Leave it enabled in CI. On self-hosted runners where Docker socket access is restricted, you may need DOCKER_HOST or TESTCONTAINERS_HOST_OVERRIDE environment variables.
Reuse Mode for Local Development
Cold-starting containers on every test run costs time. Testcontainers supports a reuse mode that leaves containers running between runs:
const container = await new PostgreSqlContainer()
.withReuse()
.start();Enable it locally with ~/.testcontainers.properties:
testcontainers.reuse.enable=trueDisable reuse in CI — you want clean state on every build.
The Confidence Trade-off
Testcontainers integration tests are slower than unit tests with mocks: expect 10-60 seconds for a full integration suite versus under a second for unit tests. Structure your test suite accordingly — run unit tests on every commit, integration tests on every pull request or nightly. The slowdown is a fair price for catching the class of bugs that mocks can never surface: version-specific SQL behavior, actual network timeouts, real serialization edge cases, and the interaction effects between your code and real infrastructure.
When your staging environment is down and you need to ship, knowing your integration tests passed against real containers is the confidence that lets you deploy anyway.