← Recipes

阅读中文版本

Run Integration Tests With Testcontainers

Start a disposable PostgreSQL container per test run, apply the real migrations, and tear it down so tests never share a database.

~35 mintestingdockerpostgresql

Task

Give integration tests a real PostgreSQL instance with the production schema, created and destroyed automatically.

When to use this

Tests run against a shared staging database, fail when a colleague leaves rows behind, and cannot run in parallel because everybody truncates the same tables.

Prerequisites

  • Docker Engine or Docker Desktop available to the test runner
  • Node.js 22 and testcontainers 10
  • Migrations that can run against an empty database

Steps

1. Install the PostgreSQL Testcontainers module and the pg driver.

pnpm add -D testcontainers @testcontainers/postgresql pg

Expected output: Both packages appear in devDependencies and the lockfile is updated.

2. Start a container and apply migrations before the suite runs.

import { before, after } from 'node:test';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import pg from 'pg';

let container;
let pool;

before(async () => {
  container = await new PostgreSqlContainer('postgres:16-alpine')
    .withDatabase('app_test')
    .withUsername('test')
    .withPassword('test')
    .start();
  pool = new pg.Pool({ connectionString: container.getConnectionUri() });
  await runMigrations(pool);
}, { timeout: 120_000 });

Expected output: A PostgreSQL 16 container starts on a random host port and the schema is applied in a few seconds.

3. Stop the pool and the container after the suite so nothing leaks into the next run.

after(async () => {
  await pool.end();
  await container.stop();
});

Expected output: The container is removed after the run and the next run starts from an empty database.

4. Assert behaviour that depends on real PostgreSQL features, not a mocked driver.

const { rows } = await pool.query(
  `INSERT INTO users (email) VALUES ('[email protected]') RETURNING id, created_at`,
);
assert.ok(rows[0].id);
await assert.rejects(
  pool.query(`INSERT INTO users (email) VALUES ('[email protected]')`),
  (err) => err.code === '23505',
);

Expected output: The insert succeeds and the duplicate raises 23505, proving the migration's constraint exists.

5. Run the integration suite in CI where Docker is available.

pnpm test:integration

Expected output: The suite passes on a clean runner; the first run pulls the image, later runs start in under five seconds.

Troubleshooting

Problem

Cannot connect to the Docker daemon in CI

Solution

The job needs access to a Docker socket. Use a runner that provides Docker, or a service container, and skip integration tests explicitly when no runtime exists.

Problem

Container startup dominates the test time

Solution

Start one container per test file or worker rather than per test, pin the image tag, and enable image caching in CI. Keep the database isolated per run.

Problem

Port 5432 is already in use

Solution

Do not publish a fixed port. Use container.getConnectionUri() or getMappedPort() so Docker assigns a free host port.

Limitations

  • Docker is a hard requirement; environments without a container runtime cannot run these tests.
  • The container is real PostgreSQL but not identical to production: extensions, tuning, and version may differ.
  • Cold image pulls add minutes to CI; cache the image or pre-pull it in the runner image.

Shared test databases rot

A staging database shared by every developer and CI job accumulates state: half-finished rows, changed settings, a migration applied by one branch and not another. Tests fail for reasons unrelated to the code, then pass on retry, and the team learns to ignore red builds. A disposable container removes the shared mutable state entirely: each run starts from the same image and the same migrations.

One container per suite, not per test

Starting PostgreSQL takes a few seconds; starting it for every test would dominate the run. Start it once in a before hook, apply migrations, and give each test its own schema or transaction so tests stay independent. If the runner executes files in parallel, each worker can own a container; the cost is bounded and isolation is perfect.

before(async () => {
  container = await new PostgreSqlContainer('postgres:16-alpine').start();
  pool = new pg.Pool({ connectionString: container.getConnectionUri() });
  await runMigrations(pool);
}, { timeout: 120_000 });

Test what a mock cannot

Mocks are useful for units, but they cannot verify a unique constraint, a partial index, a JSONB operator, or transaction isolation. Those are exactly the behaviours that break in production. With a real engine, a duplicate insert must raise 23505, a partial index must reject an active duplicate, and a serializable transaction must fail with 40001. The tests become documentation of the schema contract.

Keep it fast and reproducible

Pin the image tag (postgres:16-alpine, not postgres:latest) so a new minor release does not change behaviour overnight. Cache the image in CI, reuse it across test files where isolation allows, and avoid withReuse() for tests that mutate global state; a reused container can carry leftovers between runs.

Know the boundary

Testcontainers validates SQL and application integration, not production capacity. It will not catch a missing index on a 50-million-row table, a connection pool sized for the wrong concurrency, or a replication lag problem. Pair integration tests with at least one performance test against a production-sized dataset.