Integration testing with real dependencies in throwaway Docker containers using the Testcontainers Node.js API - GenericContainer, exposed ports, wait strategies, module containers, Docker Compose environments, and reliable cleanup.
Installs into .claude/skills of the current project.
Are you the author of Docker Testcontainers?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/pramoddutta-docker-testcontainers)
---
name: Docker Testcontainers
description: Integration testing with real dependencies in throwaway Docker containers using the Testcontainers Node.js API - GenericContainer, exposed ports, wait strategies, module containers, Docker Compose environments, and reliable cleanup.
version: 1.0.0
author: thetestingacademy
license: MIT
tags: [testcontainers, docker, integration-testing, postgres, redis, containers, ci, node]
testingTypes: [integration, database]
frameworks: [jest, vitest]
languages: [typescript]
domains: [api, devops]
agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp]
---
# Docker Testcontainers
This skill makes an AI agent write integration tests that spin up real databases, caches, and message brokers in disposable Docker containers via the `testcontainers` Node.js library - instead of mocking them or depending on a shared dev server. Trigger it when tests need a real Postgres, Redis, Kafka, or any service with Docker image, when a repo already imports `testcontainers`, or when the user complains that mocked repositories keep hiding SQL and serialization bugs.
## Core Principles
1. **Test against the real engine, not a lookalike.** SQLite in place of Postgres misses JSONB operators, transaction isolation behavior, and case-sensitivity rules. Run the exact image and major version production uses (`postgres:16-alpine`, not `latest`).
2. **Always use mapped ports.** Containers bind to random free host ports. Read them with `container.getMappedPort(5432)` and `container.getHost()`; hardcoding `localhost:5432` collides with local services and parallel CI jobs.
3. **Wait strategies, not sleeps.** A started container is not a ready service. Use `Wait.forLogMessage`, `Wait.forListeningPorts`, `Wait.forHttp`, or `Wait.forHealthCheck` so tests begin exactly when the dependency is usable.
4. **One container per suite, clean state per test.** Container startup costs seconds; start in `beforeAll`, then reset state between tests with `TRUNCATE`, `FLUSHALL`, or transaction rollbacks - not by restarting the container.
5. **Cleanup must survive failures.** Stop containers in `afterAll`; Testcontainers' Ryuk sidecar reaps anything left behind if the process dies, so never disable it in CI.
6. **Pin image tags.** `redis:latest` changing under you turns a green suite red with zero code changes. Pin to a major-minor tag and upgrade deliberately.
## Setup
```bash
npm install --save-dev testcontainers @testcontainers/postgresql
# Requires a running Docker daemon (Docker Desktop, Colima, or CI's dockerd)
docker info
```
## Patterns
### 1. GenericContainer: Redis with a wait strategy
```ts
// tests/cache.integration.test.ts
import { GenericContainer, StartedTestContainer, Wait } from 'testcontainers';
import { createClient, RedisClientType } from 'redis';
describe('rate limiter backed by Redis', () => {
let container: StartedTestContainer;
let client: RedisClientType;
beforeAll(async () => {
container = await new GenericContainer('redis:7.4-alpine')
.withExposedPorts(6379)
.withWaitStrategy(Wait.forLogMessage('Ready to accept connections'))
.withStartupTimeout(30_000)
.start();
client = createClient({
url: `redis://${container.getHost()}:${container.getMappedPort(6379)}`,
});
await client.connect();
}, 60_000);
afterEach(async () => {
await client.flushAll();
});
afterAll(async () => {
await client.quit();
await container.stop();
});
it('blocks the 6th request within a window', async () => {
for (let i = 0; i < 5; i++) {
expect(await isAllowed(client, 'user-1')).toBe(true);
}
expect(await isAllowed(client, 'user-1')).toBe(false);
});
});
async function isAllowed(client: RedisClientType, key: string): Promise<boolean> {
const count = await client.incr(`rl:${key}`);
if (count === 1) await client.expire(`rl:${key}`, 60);
return count <= 5;
}
```
### 2. Module container: Postgres with real migrations
```ts
// tests/orders.repository.integration.test.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { runMigrations } from '../src/db/migrate';
import { OrdersRepository } from '../src/db/orders-repository';
let pg: StartedPostgreSqlContainer;
let pool: Pool;
let repo: OrdersRepository;
beforeAll(async () => {
pg = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('shop_test')
.withUsername('shop')
.withPassword('shop')
.start();
pool = new Pool({ connectionString: pg.getConnectionUri() });
await runMigrations(pool); // the SAME migrations production runs
repo = new OrdersRepository(pool);
}, 90_000);
beforeEach(async () => {
await pool.query('TRUNCATE orders RESTART IDENTITY CASCADE');
});
afterAll(async () => {
await pool.end();
await pg.stop();
});
it('persists JSONB line items and filters with the @> operator', async () => {
await repo.create({ customerId: 'c1', items: [{ sku: 'SKU-1', qty: 2 }] });
await repo.create({ customerId: 'c2', items: [{ sku: 'SKU-9', qty: 1 }] });
const matches = await repo.findByItemSku('SKU-1'); // uses items @> '[{"sku":"SKU-1"}]'
expect(matches).toHaveLength(1);
expect(matches[0].customerId).toBe('c1');
});
```
### 3. Docker Compose environment for multi-service tests
```ts
// tests/api.e2e.integration.test.ts
import { DockerComposeEnvironment, StartedDockerComposeEnvironment, Wait } from 'testcontainers';
let environment: StartedDockerComposeEnvironment;
let apiBaseUrl: string;
beforeAll(async () => {
environment = await new DockerComposeEnvironment('.', 'docker-compose.test.yml')
.withWaitStrategy('api-1', Wait.forHttp('/health', 3000).forStatusCode(200))
.withWaitStrategy('postgres-1', Wait.forListeningPorts())
.up(['api', 'postgres']);
const api = environment.getContainer('api-1');
apiBaseUrl = `http://${api.getHost()}:${api.getMappedPort(3000)}`;
}, 120_000);
afterAll(async () => {
await environment.down({ timeout: 10_000 });
});
it('serves orders through the full stack', async () => {
const created = await fetch(`${apiBaseUrl}/orders`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sku: 'SKU-1', qty: 1 }),
});
expect(created.status).toBe(201);
const list = await fetch(`${apiBaseUrl}/orders`);
const orders = (await list.json()) as Array<{ sku: string }>;
expect(orders.map((o) => o.sku)).toContain('SKU-1');
});
```
### 4. Faster local loops: container reuse and env wiring
```ts
// Opt-in reuse keeps the container alive between test runs locally.
// Requires testcontainers.reuse.enable=true in ~/.testcontainers.properties
const pg = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('shop_test')
.withReuse()
.start();
// Hand the dynamic URL to the code under test the same way prod config does
process.env.DATABASE_URL = pg.getConnectionUri();
```
```ts
// Copying fixtures and running one-off commands inside a container
const container = await new GenericContainer('postgres:16-alpine')
.withEnvironment({ POSTGRES_PASSWORD: 'shop' })
.withCopyFilesToContainer([
{ source: './tests/fixtures/seed.sql', target: '/docker-entrypoint-initdb.d/seed.sql' },
])
.withExposedPorts(5432)
.start();
const { exitCode, output } = await container.exec(['psql', '-U', 'postgres', '-c', 'SELECT 1']);
expect(exitCode).toBe(0);
expect(output).toContain('1 row');
```
## Best Practices
- Raise the test framework timeout for `beforeAll` hooks that pull images (60-120 seconds); the first CI run downloads layers.
- Use module packages (`@testcontainers/postgresql`, `@testcontainers/kafka`, `@testcontainers/elasticsearch`) before reaching for `GenericContainer`; they encode correct wait strategies and credentials.
- In CI, pre-pull hot images (`docker pull postgres:16-alpine`) in a cached step to cut suite time.
- Keep integration tests in a separate script (`"test:integration": "vitest run --config vitest.integration.config.ts"`) so unit tests stay Docker-free and fast.
- Pass connection details via the same env vars production reads (`DATABASE_URL`, `REDIS_URL`); never add test-only config paths to the app.
- Log container output on failure with `container.logs()` streamed to the test reporter when diagnosing startup issues.
## Anti-Patterns
- `await sleep(3000)` after `start()` instead of a wait strategy - slow on good days, flaky on loaded CI runners.
- One shared, long-lived "test database" server that every developer and CI job mutates; state leaks make failures non-reproducible.
- Restarting the container between tests for isolation; truncate tables or roll back transactions instead and save minutes per suite.
- Mocking the repository layer in "integration" tests - the SQL is exactly the thing that needs testing.
- Using `:latest` tags or a different database engine than production.
- Disabling Ryuk (`TESTCONTAINERS_RYUK_DISABLED=true`) in CI to "fix" a permissions issue, then leaking containers until the runner dies; fix the Docker socket permissions instead.
## When to Trigger This Skill
- The user asks for integration tests against a real Postgres, MySQL, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch, or LocalStack instance.
- A repository imports `testcontainers` or `@testcontainers/*`, or contains a `docker-compose.test.yml`.
- Mock-heavy tests keep missing SQL syntax errors, migration drift, or serialization bugs that only a real engine catches.
- CI needs hermetic, parallel-safe integration tests without a provisioned shared database.
- Repository, DAO, or ORM code (Drizzle, Prisma, Knex, TypeORM) needs verification against the production database engine and real migrations.