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
- 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).
- 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.
- 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.
- 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.
- 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.
- 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
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
// 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
// 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
// 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
// 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();
// 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.
1---2name: docker-testcontainers3description: 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.4license: MIT5---67# Docker Testcontainers89This 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.1011## Core Principles12131. **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`).142. **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.153. **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.164. **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.175. **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.186. **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.1920## Setup2122```bash23npm install --save-dev testcontainers @testcontainers/postgresql24# Requires a running Docker daemon (Docker Desktop, Colima, or CI's dockerd)25docker info26```2728## Patterns2930### 1. GenericContainer: Redis with a wait strategy3132```ts33// tests/cache.integration.test.ts34import { GenericContainer, StartedTestContainer, Wait } from 'testcontainers';35import { createClient, RedisClientType } from 'redis';3637describe('rate limiter backed by Redis', () => {38 let container: StartedTestContainer;39 let client: RedisClientType;4041 beforeAll(async () => {42 container = await new GenericContainer('redis:7.4-alpine')43 .withExposedPorts(6379)44 .withWaitStrategy(Wait.forLogMessage('Ready to accept connections'))45 .withStartupTimeout(30_000)46 .start();4748 client = createClient({49 url: `redis://${container.getHost()}:${container.getMappedPort(6379)}`,50 });51 await client.connect();52 }, 60_000);5354 afterEach(async () => {55 await client.flushAll();56 });5758 afterAll(async () => {59 await client.quit();60 await container.stop();61 });6263 it('blocks the 6th request within a window', async () => {64 for (let i = 0; i < 5; i++) {65 expect(await isAllowed(client, 'user-1')).toBe(true);66 }67 expect(await isAllowed(client, 'user-1')).toBe(false);68 });69});7071async function isAllowed(client: RedisClientType, key: string): Promise<boolean> {72 const count = await client.incr(`rl:${key}`);73 if (count === 1) await client.expire(`rl:${key}`, 60);74 return count <= 5;75}76```7778### 2. Module container: Postgres with real migrations7980```ts81// tests/orders.repository.integration.test.ts82import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';83import { Pool } from 'pg';84import { runMigrations } from '../src/db/migrate';85import { OrdersRepository } from '../src/db/orders-repository';8687let pg: StartedPostgreSqlContainer;88let pool: Pool;89let repo: OrdersRepository;9091beforeAll(async () => {92 pg = await new PostgreSqlContainer('postgres:16-alpine')93 .withDatabase('shop_test')94 .withUsername('shop')95 .withPassword('shop')96 .start();9798 pool = new Pool({ connectionString: pg.getConnectionUri() });99 await runMigrations(pool); // the SAME migrations production runs100 repo = new OrdersRepository(pool);101}, 90_000);102103beforeEach(async () => {104 await pool.query('TRUNCATE orders RESTART IDENTITY CASCADE');105});106107afterAll(async () => {108 await pool.end();109 await pg.stop();110});111112it('persists JSONB line items and filters with the @> operator', async () => {113 await repo.create({ customerId: 'c1', items: [{ sku: 'SKU-1', qty: 2 }] });114 await repo.create({ customerId: 'c2', items: [{ sku: 'SKU-9', qty: 1 }] });115116 const matches = await repo.findByItemSku('SKU-1'); // uses items @> '[{"sku":"SKU-1"}]'117 expect(matches).toHaveLength(1);118 expect(matches[0].customerId).toBe('c1');119});120```121122### 3. Docker Compose environment for multi-service tests123124```ts125// tests/api.e2e.integration.test.ts126import { DockerComposeEnvironment, StartedDockerComposeEnvironment, Wait } from 'testcontainers';127128let environment: StartedDockerComposeEnvironment;129let apiBaseUrl: string;130131beforeAll(async () => {132 environment = await new DockerComposeEnvironment('.', 'docker-compose.test.yml')133 .withWaitStrategy('api-1', Wait.forHttp('/health', 3000).forStatusCode(200))134 .withWaitStrategy('postgres-1', Wait.forListeningPorts())135 .up(['api', 'postgres']);136137 const api = environment.getContainer('api-1');138 apiBaseUrl = `http://${api.getHost()}:${api.getMappedPort(3000)}`;139}, 120_000);140141afterAll(async () => {142 await environment.down({ timeout: 10_000 });143});144145it('serves orders through the full stack', async () => {146 const created = await fetch(`${apiBaseUrl}/orders`, {147 method: 'POST',148 headers: { 'content-type': 'application/json' },149 body: JSON.stringify({ sku: 'SKU-1', qty: 1 }),150 });151 expect(created.status).toBe(201);152153 const list = await fetch(`${apiBaseUrl}/orders`);154 const orders = (await list.json()) as Array<{ sku: string }>;155 expect(orders.map((o) => o.sku)).toContain('SKU-1');156});157```158159### 4. Faster local loops: container reuse and env wiring160161```ts162// Opt-in reuse keeps the container alive between test runs locally.163// Requires testcontainers.reuse.enable=true in ~/.testcontainers.properties164const pg = await new PostgreSqlContainer('postgres:16-alpine')165 .withDatabase('shop_test')166 .withReuse()167 .start();168169// Hand the dynamic URL to the code under test the same way prod config does170process.env.DATABASE_URL = pg.getConnectionUri();171```172173```ts174// Copying fixtures and running one-off commands inside a container175const container = await new GenericContainer('postgres:16-alpine')176 .withEnvironment({ POSTGRES_PASSWORD: 'shop' })177 .withCopyFilesToContainer([178 { source: './tests/fixtures/seed.sql', target: '/docker-entrypoint-initdb.d/seed.sql' },179 ])180 .withExposedPorts(5432)181 .start();182183const { exitCode, output } = await container.exec(['psql', '-U', 'postgres', '-c', 'SELECT 1']);184expect(exitCode).toBe(0);185expect(output).toContain('1 row');186```187188## Best Practices189190- Raise the test framework timeout for `beforeAll` hooks that pull images (60-120 seconds); the first CI run downloads layers.191- Use module packages (`@testcontainers/postgresql`, `@testcontainers/kafka`, `@testcontainers/elasticsearch`) before reaching for `GenericContainer`; they encode correct wait strategies and credentials.192- In CI, pre-pull hot images (`docker pull postgres:16-alpine`) in a cached step to cut suite time.193- Keep integration tests in a separate script (`"test:integration": "vitest run --config vitest.integration.config.ts"`) so unit tests stay Docker-free and fast.194- Pass connection details via the same env vars production reads (`DATABASE_URL`, `REDIS_URL`); never add test-only config paths to the app.195- Log container output on failure with `container.logs()` streamed to the test reporter when diagnosing startup issues.196197## Anti-Patterns198199- `await sleep(3000)` after `start()` instead of a wait strategy - slow on good days, flaky on loaded CI runners.200- One shared, long-lived "test database" server that every developer and CI job mutates; state leaks make failures non-reproducible.201- Restarting the container between tests for isolation; truncate tables or roll back transactions instead and save minutes per suite.202- Mocking the repository layer in "integration" tests - the SQL is exactly the thing that needs testing.203- Using `:latest` tags or a different database engine than production.204- 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.205206## When to Trigger This Skill207208- The user asks for integration tests against a real Postgres, MySQL, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch, or LocalStack instance.209- A repository imports `testcontainers` or `@testcontainers/*`, or contains a `docker-compose.test.yml`.210- Mock-heavy tests keep missing SQL syntax errors, migration drift, or serialization bugs that only a real engine catches.211- CI needs hermetic, parallel-safe integration tests without a provisioned shared database.212- Repository, DAO, or ORM code (Drizzle, Prisma, Knex, TypeORM) needs verification against the production database engine and real migrations.