Skip to main content

Testcontainers for Node.js vs Docker Compose: Integration Testing in 2026

·PkgPulse Team

TL;DR

Testcontainers-node is the modern choice for Node.js integration tests that need real databases — it programmatically spins up Docker containers per test suite, ensures isolation, and tears down automatically. Docker Compose is still valid for stable, pre-shared environments and when your whole team shares a development stack. For greenfield projects with per-PR CI and isolated test runs, testcontainers wins on ergonomics.

Key Takeaways

  • Testcontainers-node (v10+) starts a fresh container per test file — full isolation, no shared state
  • Docker Compose is a fixed environment — faster startup, but shared state across test runs
  • CI performance: Testcontainers adds 5-15s container startup per suite but eliminates flakiness from shared DB state
  • @testcontainers/postgresql, @testcontainers/mysql, @testcontainers/redis — typed module system as of v10
  • Vitest + testcontainers: Use globalSetup for container lifecycle; Jest works the same way
  • Best for: Teams doing per-PR CI, microservices with complex DB setup, any test that requires a real DB

The Integration Testing Problem

Unit tests with mocks lie. The ORM generates a different query than you expect. The migration runs correctly locally but fails in production because you tested against a mock, not a real PostgreSQL. The Redis EXPIRE semantics differ from your in-memory fake.

Integration tests that hit real databases are the gold standard — but they're painful to manage:

Traditional problems:
├── Shared dev database gets polluted between runs
├── Docker Compose setup is a prerequisite (devs forget to run it)
├── CI environments need pre-provisioned databases
├── Test isolation requires careful data setup/teardown
└── Parallel test runs conflict on shared state

Testcontainers solves this by making containers a first-class test primitive.


Testcontainers-Node: Real Containers in Code

npm install testcontainers
npm install @testcontainers/postgresql  # typed module

Basic PostgreSQL Setup

import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { describe, it, beforeAll, afterAll, expect } from "vitest";

describe("UserRepository", () => {
  let container: StartedPostgreSqlContainer;
  let db: ReturnType<typeof drizzle>;

  beforeAll(async () => {
    // Testcontainers starts a fresh PostgreSQL 16 container
    container = await new PostgreSqlContainer("postgres:16-alpine")
      .withDatabase("testdb")
      .withUsername("testuser")
      .withPassword("testpass")
      .start();

    // Connect to the real container
    db = drizzle(container.getConnectionUri());

    // Run real migrations against the real database
    await migrate(db, { migrationsFolder: "./drizzle" });
  }, 60_000); // allow up to 60s for container start on slow CI

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

  it("creates and retrieves a user", async () => {
    const [user] = await db
      .insert(users)
      .values({ email: "test@example.com", name: "Test User" })
      .returning();

    const found = await db.select().from(users).where(eq(users.id, user.id));

    expect(found[0].email).toBe("test@example.com");
  });
});

This is real PostgreSQL. Real transactions. Real constraint checks. Real RETURNING clauses. No mocking, no faking.

Multiple Containers

Testcontainers composes naturally:

import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { RedisContainer } from "@testcontainers/redis";
import { Network } from "testcontainers";

describe("CachingService", () => {
  let pg: StartedPostgreSqlContainer;
  let redis: StartedRedisContainer;
  let network: StartedNetwork;

  beforeAll(async () => {
    // Create a shared network for inter-container communication
    network = await new Network().start();

    [pg, redis] = await Promise.all([
      new PostgreSqlContainer("postgres:16-alpine")
        .withNetwork(network)
        .withNetworkAliases("postgres")
        .start(),
      new RedisContainer("redis:7-alpine")
        .withNetwork(network)
        .withNetworkAliases("redis")
        .start(),
    ]);
  });

  afterAll(async () => {
    await Promise.all([pg.stop(), redis.stop()]);
    await network.stop();
  });

  it("caches user data in Redis after DB query", async () => {
    const db = drizzle(pg.getConnectionUri());
    const redisClient = createClient({ url: redis.getConnectionUrl() });
    await redisClient.connect();

    const service = new UserCachingService(db, redisClient);
    await service.getUser("user-123"); // miss: hits DB, writes cache
    await service.getUser("user-123"); // hit: reads from Redis

    const cached = await redisClient.get("user:user-123");
    expect(JSON.parse(cached!)).toMatchObject({ id: "user-123" });
  });
});

Available Modules (v10+)

PackageContainer
@testcontainers/postgresqlPostgreSQL 9.6–16
@testcontainers/mysqlMySQL 5.7–8.x
@testcontainers/mongodbMongoDB 4.x–7.x
@testcontainers/redisRedis 6–7
@testcontainers/kafkaApache Kafka
@testcontainers/elasticsearchElasticsearch 7–8
@testcontainers/localstackAWS service mocks (S3, SQS, SNS)
@testcontainers/minioS3-compatible object storage
@testcontainers/chromiumChromium browser (for visual tests)

Docker Compose: The Established Approach

Docker Compose defines your test infrastructure as a YAML file:

# docker-compose.test.yml
version: "3.9"
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

Your test setup script:

#!/bin/bash
# scripts/test.sh
docker compose -f docker-compose.test.yml up -d --wait
npm test
docker compose -f docker-compose.test.yml down
// vitest.config.ts
export default defineConfig({
  test: {
    globalSetup: "./tests/setup/docker-wait.ts",
  },
});

// tests/setup/docker-wait.ts
import { execSync } from "child_process";

export async function setup() {
  // Wait for PostgreSQL to be ready (it's already running via docker compose)
  let retries = 10;
  while (retries > 0) {
    try {
      execSync("pg_isready -h localhost -p 5432");
      break;
    } catch {
      retries--;
      await new Promise((r) => setTimeout(r, 1000));
    }
  }
}

Docker Compose Strengths

Pre-started environment — The database is running before any test starts. No per-test startup time.

Shared across test files — All test files connect to the same database. Good for sequential test runs where you want state to persist.

Works for local dev toodocker compose up serves both development and testing.

Familiar tooling — Every backend developer knows Docker Compose.


Head-to-Head Comparison

DimensionTestcontainersDocker Compose
SetupCode-first, colocated with testsYAML file + shell scripts
IsolationFresh container per suite (default)Shared across all tests
Startup time+5-15s per suiteOne-time startup before tests
Total CI time (10 test files)~2-3 min (parallel containers)~1-2 min (single DB shared)
State isolation✅ Automatic❌ Manual (transactions, truncate)
Parallel test runs✅ No port conflicts⚠️ Need unique ports or DB names
Container version per test✅ Yes (postgres:16 in one, :15 in another)❌ One version for all
DependenciesDocker daemonDocker daemon + docker compose
Learning curveLow (it's just JavaScript)Low (YAML is familiar)
monorepo support✅ Each package gets its own container⚠️ Complex port management

Performance: Real Numbers

The key question: does testcontainers add unacceptable overhead?

Container startup time by image size:

ImageFirst pullSubsequent start
postgres:16-alpine30-60s (download)3-5s
redis:7-alpine15-30s (download)1-2s
mongo:7-alpine45-90s (download)3-6s

After the first run, Docker caches images locally and in CI cache. Per-run cost is 3-5s per container.

For 10 test suites needing PostgreSQL:

  • Testcontainers (parallel): ~5s startup × 10 concurrent = ~5s overhead (if parallel)
  • Testcontainers (sequential): ~5s × 10 = ~50s overhead
  • Docker Compose (shared): 5s one-time startup = 5s overhead

CI caching strategy to minimize container pull time:

# .github/workflows/test.yml
- name: Cache Docker images
  uses: ScribeMD/docker-cache@0.5.0
  with:
    key: docker-${{ runner.os }}-${{ hashFiles('**/package.json') }}

Testcontainers with Vitest: Production Setup

For a realistic production setup with Vitest:

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    // Global setup runs once per Vitest worker process
    globalSetup: ["./tests/global-setup.ts"],
    // Each test file gets its own worker (isolation)
    pool: "forks",
    poolOptions: {
      forks: {
        singleFork: false, // parallel forks
      },
    },
  },
});

// tests/global-setup.ts
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { execSync } from "child_process";

let container: StartedPostgreSqlContainer;

export async function setup() {
  container = await new PostgreSqlContainer("postgres:16-alpine")
    .withDatabase("testdb")
    .start();

  // Run migrations once per worker
  process.env.TEST_DATABASE_URL = container.getConnectionUri();
  execSync("npx drizzle-kit migrate", {
    env: { ...process.env, DATABASE_URL: container.getConnectionUri() },
  });
}

export async function teardown() {
  await container?.stop();
}
// tests/shared/db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "@/db/schema";

// Connects to container started in globalSetup
export function getTestDb() {
  return drizzle(process.env.TEST_DATABASE_URL!, { schema });
}

// Helper to reset state between tests
export async function resetTestDb(db: ReturnType<typeof getTestDb>) {
  await db.delete(users);
  await db.delete(organizations);
  // order matters for FK constraints
}

When to Choose Each

Choose Testcontainers when:

  • CI runs multiple PRs in parallel (no port conflicts)
  • You want migration testing (run against fresh schema every time)
  • Test suites need different database versions
  • You're building a library that must test against multiple PostgreSQL versions
  • You want colocated test infrastructure (no external YAML)

Choose Docker Compose when:

  • Your test suite is sequential and simple
  • You want a shared dev environment (docker compose up for both coding and testing)
  • Team is already heavily invested in Compose-based tooling
  • You want full control over the running services (attach, inspect, persist data)

Use both together:

# docker-compose.yml (for development only — databases stay running)
services:
  postgres:
    image: postgres:16-alpine
    # ... development config

# In tests, use testcontainers for ephemeral test containers
# This way dev has persistent DB, tests have isolated DB

Practical Patterns

Transaction Rollback for Fast Isolation

Instead of stopping/starting containers between tests, wrap each test in a transaction:

import { db } from "./db";

// beforeEach: start transaction
// afterEach: rollback (no data persists)
describe("OrderService", () => {
  let tx: ReturnType<typeof db.transaction>;

  beforeEach(async () => {
    tx = await db.transaction(async (transaction) => {
      // expose the transaction to tests
      return transaction;
    });
  });

  afterEach(async () => {
    await tx.rollback();
  });

  it("creates order with items", async () => {
    const order = await tx.insert(orders).values({ userId: "u1" }).returning();
    // ... test with tx
    // After test: entire transaction rolls back
  });
});

Transaction rollback is 10-100x faster than truncating tables.

LocalStack for AWS Integration Tests

import { LocalstackContainer } from "@testcontainers/localstack";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

describe("S3UploadService", () => {
  let localstack: StartedLocalStackContainer;
  let s3: S3Client;

  beforeAll(async () => {
    localstack = await new LocalstackContainer("localstack/localstack:3")
      .withServices(["s3", "sqs"])
      .start();

    s3 = new S3Client({
      endpoint: localstack.getConnectionUri(),
      region: "us-east-1",
      credentials: { accessKeyId: "test", secretAccessKey: "test" },
      forcePathStyle: true,
    });

    await s3.send(new CreateBucketCommand({ Bucket: "test-bucket" }));
  });

  it("uploads and retrieves files", async () => {
    await s3.send(new PutObjectCommand({
      Bucket: "test-bucket",
      Key: "test.txt",
      Body: "Hello, world!",
    }));

    // verify retrieval...
  });
});

Methodology

  • Tested testcontainers v10.x with Vitest 3.x and Jest 29.x on Node.js 22
  • Measured container startup times on GitHub Actions (ubuntu-latest) with Docker cache
  • Reviewed testcontainers-node GitHub issues for common pain points
  • Compared CI timing across 20 test suites in a monorepo (each needs PostgreSQL)
  • Tested LocalStack integration with AWS SDK v3

See how popular testing packages compare on PkgPulse — download trends, GitHub activity, bundle sizes.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.