Skip to main content

Testcontainers: Real Database Integration Testing for Node.js

Testcontainers is a Node.js library that spins up real Docker containers — PostgreSQL, Redis, MongoDB, Kafka, and more — for use inside your test suite. It solves the fundamental problem with mocked databases: the mock passes, but the real query fails in production. With Testcontainers, each test run uses a fresh, real container that starts before tests and stops when they finish.

✓ Real PostgreSQL, Redis, MongoDB in tests ✓ Automatic container cleanup via Ryuk ✓ Works with Jest and Vitest ✓ No docker-compose files to manage

Why Real Containers Beat Mocks

Mocks simulate a database by returning hardcoded values. They cannot catch: SQL syntax errors, constraint violations, index performance, serialization differences between pg-node and raw Postgres, or Redis EXPIRE behaviour. A mock-based test that passes can coexist with a production bug for months.

Testcontainers runs the same Docker image you use in production. Your queries, migrations, and ORM code run against the real engine. If the test passes, it works. If it fails, it fails now — not in production at 3am.

Installation

npm install --save-dev testcontainers

# Requires Docker to be running locally or in CI
docker --version  # verify Docker is available

PostgreSQL Container with Jest

// tests/user.integration.test.ts
import { PostgreSqlContainer } from 'testcontainers';
import { Pool } from 'pg';

let container: PostgreSqlContainer;
let pool: Pool;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16')
    .withDatabase('testdb')
    .withUsername('testuser')
    .withPassword('testpass')
    .start();

  pool = new Pool({ connectionString: container.getConnectionUri() });
  await pool.query('CREATE TABLE users (id SERIAL, name TEXT)');
}, 60_000);  // allow 60s for image pull

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

it('inserts and retrieves a user', async () => {
  await pool.query("INSERT INTO users (name) VALUES ('Alice')");
  const result = await pool.query('SELECT name FROM users');
  expect(result.rows[0].name).toBe('Alice');
});

Redis Container for Cache Tests

import { RedisContainer } from 'testcontainers';
import { createClient } from 'redis';

let container: RedisContainer;
let client: ReturnType<typeof createClient>;

beforeAll(async () => {
  container = await new RedisContainer('redis:7').start();
  client = createClient({ url: container.getConnectionUrl() });
  await client.connect();
}, 30_000);

afterAll(async () => {
  await client.quit();
  await container.stop();
});

it('sets and gets a key with TTL', async () => {
  await client.set('session:abc', 'user123', { EX: 300 });
  const val = await client.get('session:abc');
  expect(val).toBe('user123');
  const ttl = await client.ttl('session:abc');
  expect(ttl).toBeGreaterThan(0);
});

Container Lifecycle and Parallel Isolation

// Start multiple containers in parallel:
const [pg, redis] = await Promise.all([
  new PostgreSqlContainer().start(),
  new RedisContainer().start(),
]);

// Each container gets a random host port — no conflicts
console.log(pg.getMappedPort(5432));   // e.g., 54321
console.log(redis.getMappedPort(6379)); // e.g., 63791

Using with Vitest

// vitest.config.ts
export default defineConfig({
  test: {
    testTimeout: 60_000,  // containers need startup time
    hookTimeout: 60_000,
    pool: 'forks',        // required for Docker socket access in Vitest
  }
});

Testcontainers vs docker-compose for Tests

Aspect Testcontainers docker-compose
Setup In test code (TypeScript) Separate YAML file
Lifecycle Auto start/stop per suite Manual up/down commands
Isolation Per-test or per-suite Shared across all tests
Port conflicts No — random ports Yes — fixed ports
CI complexity Just run npm test Requires compose pre-step
Share this tool: X / Twitter LinkedIn
✓ 60,000+ developers use DevPlaybook ★★★★★ 4.8 / 5 on Gumroad ✓ MIT licensed · Instant download ✓ No login required

Related Products

on Gumroad

You Might Also Need

More Free Tools

Enjoying this tool? Get more like it.

Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.

DevPlaybook Pro 7 days free

Unlock AI-Powered Dev Tools

  • ⚡ AI Code Review, Doc Generator & SQL Builder
  • ⚡ All premium templates & early access
  • ⚡ Member discounts on Gumroad products
Start Free Trial →

$9/mo after trial · Cancel anytime

Want the full toolkit?

Get DevPlaybook Pro

Every template, guide, boilerplate, and automation script in one bundle. 13 premium products — grab them all at once and save big.

$79
$241 separately Save $162 (67% off)
Get DevPlaybook Pro — $79 →

MIT licensed · Instant download · No subscription

See what's included → Browse all deals →

Frequently Asked Questions

What is the difference between unit tests with mocks and integration tests with Testcontainers?▼

Unit tests with mocks replace real dependencies (databases, caches, queues) with fake objects that return predefined responses. This makes them fast and isolated, but they can pass even when the real integration is broken. Testcontainers runs a real Docker container — a real PostgreSQL, a real Redis — so your integration test exercises the actual SQL queries, real connection pooling, real Redis serialization. If something breaks in production, a Testcontainers test will catch it; a mock often will not.

How do I use Testcontainers with Jest?▼

Install testcontainers and the relevant module: npm install --save-dev testcontainers. In your Jest test file, use beforeAll to start the container and store the connection URL, then pass that URL to your database client. Use afterAll to call container.stop(). Set testTimeout in jest.config to at least 30000ms because container startup takes time. Using --runInBand prevents parallel containers from conflicting on the same host port.

How does Testcontainers handle container cleanup?▼

By default, Testcontainers registers a JVM (or Node process) shutdown hook that stops containers when the test process exits. If you call container.stop() in afterAll, cleanup is explicit and reliable. Testcontainers also supports a Ryuk sidecar container (enabled by default) that automatically removes any containers that were not explicitly stopped — useful if a test process crashes mid-run. To disable Ryuk, set TESTCONTAINERS_RYUK_DISABLED=true.

How do I run Testcontainers in GitHub Actions CI?▼

GitHub Actions runners include Docker by default on ubuntu-latest. No extra setup is needed — just include Testcontainers in your test suite and run npm test in the workflow. The Docker daemon is already running. For self-hosted runners, ensure the runner user has Docker socket access. If you see socket permission errors, add the runner user to the docker group. Testcontainers Cloud is an alternative that offloads container orchestration to a remote daemon.

Can Testcontainers run multiple containers per test suite?▼

Yes. You can start multiple containers independently in separate beforeAll blocks or use the Network API to create containers that communicate with each other on a shared Docker network. A common pattern is a PostgreSQL container + a Redis container started in parallel with Promise.all([pgContainer.start(), redisContainer.start()]) to minimise startup time. Each container gets its own dynamically assigned host port to avoid conflicts.

How does Testcontainers Cloud differ from local Docker?▼

Local Docker runs containers on your machine or CI runner. Testcontainers Cloud (by AtomicJar, now part of Docker) offloads container execution to a remote cloud daemon. This is useful for teams with slow local machines, strict CI environments where Docker is unavailable, or when you want to avoid Docker-in-Docker complexity in Kubernetes-based CI. The API is identical — you change only an environment variable, not your test code.