Skip to content
Knowledge Hub

Integration tests with Testcontainers: a real Postgres, Redis and Kafka per run, without slowing CI to a crawl Intermediate

Estimated reading time: 10 min read

A NestJS order service has two pieces worth testing together: an OrderRepository on PostgreSQL and an OrderService that also caches the status in Redis and publishes order.created to Kafka.

The suite runs green on SQLite in memory and ioredis-mock. In production, a client sends currency: "USDT" and the insert fails with 22001 value too long. The tests never saw it: SQLite ignores the 3 in char(3).

This article answers: how do you run tests against the real engines, keep them isolated from each other, and keep CI fast enough that people do not skip them?

Choosing the test layer is covered in Unit, integration and e2e; this article starts after that decision.

Situation Choose Condition
Repository code, constraints, SQL dialect, migrations Real Postgres via Testcontainers Docker available locally and in CI
Many test files, one engine One container per run (global setup) + one database per file cloned from a template Migrations run once
Test uses a single connection you control BEGIN … ROLLBACK per test Code under test does not open its own transaction or use another connection
Code uses a pool, commits, or runs work in the background TRUNCATE after each test Tables are small; no other file shares the database
A test needs a fresh, fully migrated database CREATE DATABASE … TEMPLATE ~35 ms per test is acceptable
Pure business logic, external SaaS APIs Do not use containers Unit tests, or contract tests and stubs

A test double replaces a real dependency: a mock asserts calls, a fake reimplements behaviour (SQLite as “a database”, ioredis-mock as “Redis”). A fake only behaves like what its authors reimplemented. It does not run the engine’s code.

The same INSERT was run on both engines in sqlite-vs-postgres.test.ts (SQLite 3.53 via node:sqlite, PostgreSQL 17):

Input SQLite (fake) PostgreSQL (real)
currency = 'USDT' into char(3) Stored as USDT Error 22001
total_cents = 1250.5 into integer Stored, typeof = real Error 22P02
'Alice@Shop.vn' LIKE 'alice@shop.vn' 1 (match) false

The SQLite docs confirm it: length arguments such as VARCHAR(255) are ignored. STRICT tables reject 1250.5 but do not accept CHAR(3) at all.

Testcontainers is a library that starts throwaway Docker containers from test code, waits until they are ready, and hands back a host and a random port. The Node packages are testcontainers plus modules such as @testcontainers/postgresql, @testcontainers/redis and @testcontainers/kafka; the current major version is 12 (12.2.1 in this demo).

A container per test file repeats the same startup in every file. The demo starts one Postgres per run and gives each file its own database:

One container per run: migrate once into a template, clone one database per test file, Ryuk cleans up if the runner dies
One container per run: migrate once into a template, clone one database per test file, Ryuk cleans up if the runner diesStep 1/6: globalSetup runs once. The first container start also launches Ryuk and opens a connection to it.node --testglobalSetupRyukreaper containerwatchingorder-repositorychild process Aorder-servicechild process BPostgres containerrandom host portsession label
node --test → Ryuksession label
Step 1/6

globalSetup runs once. The first container start also launches Ryuk and opens a connection to it.

State after each step:

  1. Ryuk starts: the first container start also launches the Ryuk reaper for this session’s resources.
  2. Postgres ready: the module’s default wait strategy is a pg_isready health check and bound ports, so no sleep is needed.
  3. Migrations once: the schema goes into app_template; the runner disconnects, because PostgreSQL refuses to copy a template while anyone is connected.
  4. One database per file: each child process runs CREATE DATABASE test_<pid> TEMPLATE app_template. Files never see each other’s rows.
  5. Per-test isolation: inside its database, each file picks rollback or TRUNCATE.
  6. Cleanup: globalTeardown stops the container; after a crash, Ryuk removes it.

The Node runner (since v24.0, “Early development”) calls globalSetup once before spawning test-file processes, so an environment variable set there reaches every file:

// postgres-global.ts (loaded through global-setup.js, see below)
export async function globalSetup() {
container = await startMigratedPostgres(); // PostgreSqlContainer('postgres:17-alpine')
process.env.TC_PG_ADMIN_URI = adminUriOf(container); // .../postgres on the random port
}
export async function globalTeardown() {
await container?.stop();
}

Vitest offers the same split: globalSetup runs once before the workers and passes values to tests with project.provide() and inject().

Measured cost per test, 100 rounds, 20 seeded rows plus one insert (bench.ts):

Strategy Cost/test Breaks when
BEGIN … ROLLBACK 0.8–1.1 ms Code commits itself, uses a pool, or another connection must see the rows
TRUNCATE orders RESTART IDENTITY 1.3–1.7 ms Two files share a database (it takes an ACCESS EXCLUSIVE lock)
CREATE DATABASE … TEMPLATE + DROP 33–39 ms Rarely; just slower

Loading diagram…

In NestJS, rollback per test fits code that takes its connection from a transaction context, as in transactions with CLS. OrderService takes a Pool, so its test file truncates.

  • Wait strategies: without a module or health check, Testcontainers waits up to 60 s for ports. Options include Wait.forLogMessage, forHealthCheck, forHttp, forSuccessfulCommand and forAll; the Postgres module combines health check and ports with a 120 s startup timeout.
  • Ports: every exposed port maps to a random host port (getMappedPort(5432)). Parallel files and parallel CI jobs never collide; fixed ports are “not recommended”.
  • Parallel workers: node --test runs each file in a child process, up to os.availableParallelism() - 1 at once. Nine files cloning one template at once worked in both 12-file runs.
  • Ryuk: a container that deletes everything carrying the session label about 10 s after the test process’s connection closes (its default reconnection timeout). Killing the runner with SIGKILL removed the Redis container after 11 s.
  • TESTCONTAINERS_RYUK_DISABLED=true turns Ryuk off; globalTeardown is then the only cleanup.
  • Reuse: .withReuse() keeps a container with the same configuration for the next run. It saves seconds locally; it also keeps data, so leave it out of CI.
  • CI: GitHub-hosted ubuntu-24.04 runners ship Docker Engine, so no service setup is needed:
jobs:
integration:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- run: npm ci --prefix examples
- run: npm --prefix examples run test:testcontainers

With ts-node, keep the --test-global-setup file as plain JS (global-setup.js): the runner import()s it, bypassing the TypeScript hook.

The demo in examples/src/testcontainers/ has a NestJS OrdersModule; tests swap its PG_POOL, REDIS and KAFKA_PRODUCER providers via overrideProvider(...).

Terminal window
npm --prefix examples run test:testcontainers # 3 files, 9 tests
npm --prefix examples run bench:testcontainers # startup and isolation timings
Actual output (trimmed)
[global-setup] postgres + migrations ready in 1383 ms
[order-repository] shared-container database ready in 138 ms
[order-service] postgres database: 57 ms
[order-service] kafka container: 258 ms
[order-service] redis container: 769 ms
[order-service] kafka ready (createTopics): 2227 ms
[order-service] consumer group joined: 419 ms
ℹ pass 9
ℹ fail 0
ℹ duration_ms 6276.618625

Over 15 consecutive runs the suite took 5.7–8.0 s, all green. Kafka dominates: the broker accepted requests 2.2–4.6 s after start() resolved.

Machine and limits: Apple M5 (10 cores), Docker 29.5.3, Node 24.17, testcontainers 12.2.1, images already pulled. A cold CI runner also pays the image pull; numbers vary per run.

Real engines in containers

  • Constraints, SQL dialect, LIKE, types and migrations behave as in production.
  • Random ports and per-file databases let files run in parallel.
  • Ryuk cleans up after crashes; nothing to install except Docker.

What it costs

  • Needs a Docker-compatible runtime locally and in CI.
  • Seconds of startup (Kafka the slowest here), plus image pulls on cold runners.
  • A green suite still depends on the image tag matching production’s major version.
  1. Pin image tags to production’s major version (postgres:17-alpine), never latest.
  2. Start each engine once per run in global setup; run migrations once into a template database.
  3. Give each test file its own database (CREATE DATABASE … TEMPLATE) so files can run in parallel.
  4. Pick per-test isolation from how the code uses connections: rollback, TRUNCATE, or a fresh clone.
  5. Treat start() as “container up”, then make one real client call (query, createTopics) before the first test.
  6. Keep Ryuk on in CI; use reuse only on developer machines.
  7. Log startup timings in the suite so a slow image or a Kafka upgrade shows up in CI output.

The demo’s strangest numbers came from this:

The second mistake is about connections:

Rolling back a transaction the code under test never used

Common

beforeEach(() => client.query('BEGIN')); // on client #1
// OrderService writes through the Pool: other connections, auto-commit
afterEach(() => client.query('ROLLBACK')); // rows stay behind

Prefer

afterEach(async () => {
await pool.query('TRUNCATE orders RESTART IDENTITY');
await redis.flushdb();
});

Why

Rollback only undoes what ran on that one connection. Code that uses a connection pool needs a reset that works across connections.

Misconception Consequence What to do
One container per test file is the default 12 files: 20–29 s instead of 5–6 s Global setup + one database per file
Fixed host ports (5432:5432) Parallel jobs collide Use getMappedPort()
sleep(5000) after start Slow and still flaky Wait strategy + one real client call
  1. Why does SQLite accept 'USDT' into char(3) while PostgreSQL returns 22001?
  2. CREATE DATABASE test_a TEMPLATE app_template fails right after migrations. What is still connected?
  3. A test file uses rollback per test, but the service writes through a Pool. Why do rows leak into the next test?
  4. The runner is killed with SIGKILL. What removes the containers, and roughly when?