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
The problem
Section titled “The problem”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.
What to choose (summary first)
Section titled “What to choose (summary first)”| 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 |
Core concepts
Section titled “Core concepts”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).
How it works
Section titled “How it works”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:
session labelglobalSetup runs once. The first container start also launches Ryuk and opens a connection to it.
State after each step:
- Ryuk starts: the first container start also launches the Ryuk reaper for this session’s resources.
- Postgres ready: the module’s default wait strategy is a
pg_isreadyhealth check and bound ports, so nosleepis needed. - Migrations once: the schema goes into
app_template; the runner disconnects, because PostgreSQL refuses to copy a template while anyone is connected. - One database per file: each child process runs
CREATE DATABASE test_<pid> TEMPLATE app_template. Files never see each other’s rows. - Per-test isolation: inside its database, each file picks rollback or
TRUNCATE. - Cleanup:
globalTeardownstops 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().
Isolating tests from each other
Section titled “Isolating tests from each other”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.
Operational details
Section titled “Operational details”- Wait strategies: without a module or health check, Testcontainers waits up to 60 s for ports. Options include
Wait.forLogMessage,forHealthCheck,forHttp,forSuccessfulCommandandforAll; 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 --testruns each file in a child process, up toos.availableParallelism() - 1at 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
SIGKILLremoved the Redis container after 11 s. TESTCONTAINERS_RYUK_DISABLED=trueturns Ryuk off;globalTeardownis 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.04runners 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:testcontainersWith ts-node, keep the --test-global-setup file as plain JS (global-setup.js): the runner import()s it, bypassing the TypeScript hook.
Real-world example
Section titled “Real-world example”The demo in examples/src/testcontainers/ has a NestJS OrdersModule; tests swap its PG_POOL, REDIS and KAFKA_PRODUCER providers via overrideProvider(...).
npm --prefix examples run test:testcontainers # 3 files, 9 testsnpm --prefix examples run bench:testcontainers # startup and isolation timingsActual 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.618625Over 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.
Trade-offs
Section titled “Trade-offs”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.
Production checklist
Section titled “Production checklist”- Pin image tags to production’s major version (
postgres:17-alpine), neverlatest. - Start each engine once per run in global setup; run migrations once into a template database.
- Give each test file its own database (
CREATE DATABASE … TEMPLATE) so files can run in parallel. - Pick per-test isolation from how the code uses connections: rollback,
TRUNCATE, or a fresh clone. - Treat
start()as “container up”, then make one real client call (query,createTopics) before the first test. - Keep Ryuk on in CI; use reuse only on developer machines.
- Log startup timings in the suite so a slow image or a Kafka upgrade shows up in CI output.
Common mistakes
Section titled “Common mistakes”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-commitafterEach(() => client.query('ROLLBACK')); // rows stay behindPrefer
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 |
Self-check
Section titled “Self-check”- Why does SQLite accept
'USDT'intochar(3)while PostgreSQL returns22001? CREATE DATABASE test_a TEMPLATE app_templatefails right after migrations. What is still connected?- A test file uses rollback per test, but the service writes through a
Pool. Why do rows leak into the next test? - The runner is killed with
SIGKILL. What removes the containers, and roughly when?
Summary
Section titled “Summary”References
Section titled “References”- Testcontainers — PostgreSQL: module API,
snapshot()/restoreSnapshot(). - Testcontainers — Wait strategies: default port wait, other strategies, startup timeout.
- Testcontainers — Containers: random host ports,
withReuse(), stop behaviour. - Testcontainers — Configuration:
TESTCONTAINERS_RYUK_DISABLEDand other environment variables. - moby-ryuk README: when Ryuk removes resources, its timeouts.
- Node.js — Global setup,
--test-concurrency: one setup per run, parallel files. - PostgreSQL 17 — Template Databases: why the template must have no connections.
- PostgreSQL 17 — TRUNCATE:
RESTART IDENTITY, theACCESS EXCLUSIVElock. - SQLite — Datatypes: type affinity, ignored length arguments, STRICT tables.
- Runner images — Ubuntu 24.04: Docker preinstalled.