Testcontainers for Database Testing: Postgres, Redis, and MongoDB in Node.js and Python

Testcontainers for Database Testing: Postgres, Redis, and MongoDB in Node.js and Python

Testcontainers eliminates the gap between mocked database behavior and real database behavior by spinning up actual Docker containers during test runs. This post covers the full setup for Postgres, Redis, and MongoDB in both Node.js and Python, including parallel container execution, GitHub Actions integration, and concrete techniques to keep container startup from slowing your CI pipeline.

Key Takeaways

Testcontainers gives you production-equivalent databases in tests — no mocking, no drift between test behavior and real behavior, no "works on my machine" database version surprises. Container startup is the biggest cost — amortize it with beforeAll — starting one container per test suite rather than per test cuts the overhead from minutes to seconds. Parallel container execution cuts total wall-clock time significantlyPromise.all in Node.js and asyncio.gather in Python start multiple containers simultaneously instead of sequentially. GitHub Actions with Docker-in-Docker requires no special configuration — the ubuntu-latest runner has Docker pre-installed; Testcontainers works out of the box with no additional setup services. Pull images in CI before tests run — a docker pull step in your workflow warms the layer cache and makes container startup deterministic rather than dependent on network speed at test time.

Integration tests that mock database clients are better than no tests, but they create a gap: mock behavior diverges from real behavior. A Redis mock that returns values synchronously will not surface bugs caused by network latency. A PostgreSQL mock that ignores transaction isolation levels will not catch deadlocks. Testcontainers closes this gap by running actual Docker containers — the same databases your application uses in production — as part of your test suite.

This post walks through practical Testcontainers setup for Postgres, Redis, and MongoDB in both Node.js and Python, with parallel execution, GitHub Actions CI, and performance techniques that keep your pipeline fast.

How Testcontainers Works

Testcontainers communicates with the Docker daemon directly from test code. When a test suite starts, it pulls the requested image (or uses a cached version), starts a container with randomized ports, and gives you connection details your application code uses to connect. When the suite ends, the container is stopped and removed.

The randomized port mapping is the key insight: Testcontainers never conflicts with locally running database services and allows multiple test suites to run in parallel without port collisions.

Node.js Setup

Install the library and the specific container modules you need:

npm install --save-dev testcontainers

You need Docker running locally. On CI, the standard ubuntu-latest GitHub Actions runner works without any additional configuration.

PostgreSQL in Node.js

// test/postgres.integration.spec.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from 'testcontainers';
import { Pool } from 'pg';

describe('PostgreSQL integration', () => {
  let container: StartedPostgreSqlContainer;
  let pool: Pool;

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

    pool = new Pool({
      host: container.getHost(),
      port: container.getPort(),
      database: container.getDatabase(),
      user: container.getUsername(),
      password: container.getPassword(),
    });

    // Run your schema migrations here
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        email TEXT UNIQUE NOT NULL,
        created_at TIMESTAMPTZ DEFAULT NOW()
      )
    `);
  }, 60_000); // allow up to 60s for container pull + start

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

  beforeEach(async () => {
    await pool.query('TRUNCATE users RESTART IDENTITY CASCADE');
  });

  it('inserts and retrieves a user', async () => {
    await pool.query(
      'INSERT INTO users (email) VALUES ($1)',
      ['test@example.com']
    );

    const result = await pool.query(
      'SELECT email FROM users WHERE email = $1',
      ['test@example.com']
    );

    expect(result.rows).toHaveLength(1);
    expect(result.rows[0].email).toBe('test@example.com');
  });

  it('enforces unique constraint on email', async () => {
    await pool.query('INSERT INTO users (email) VALUES ($1)', ['a@example.com']);

    await expect(
      pool.query('INSERT INTO users (email) VALUES ($1)', ['a@example.com'])
    ).rejects.toThrow(/unique/i);
  });
});

Redis in Node.js

// test/redis.integration.spec.ts
import { RedisContainer, StartedRedisContainer } from 'testcontainers';
import { createClient, RedisClientType } from 'redis';

describe('Redis integration', () => {
  let container: StartedRedisContainer;
  let client: RedisClientType;

  beforeAll(async () => {
    container = await new RedisContainer('redis:7-alpine').start();

    client = createClient({
      url: container.getConnectionUrl(),
    }) as RedisClientType;

    await client.connect();
  }, 30_000);

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

  beforeEach(async () => {
    await client.flushAll();
  });

  it('sets and gets a string value', async () => {
    await client.set('session:abc', JSON.stringify({ userId: 42 }));
    const raw = await client.get('session:abc');

    expect(JSON.parse(raw!)).toEqual({ userId: 42 });
  });

  it('respects TTL expiry', async () => {
    await client.set('temp', 'value', { EX: 1 });
    await new Promise(r => setTimeout(r, 1100));

    const result = await client.get('temp');
    expect(result).toBeNull();
  });

  it('increments a counter atomically', async () => {
    await Promise.all([
      client.incr('hits'),
      client.incr('hits'),
      client.incr('hits'),
    ]);

    const count = await client.get('hits');
    expect(Number(count)).toBe(3);
  });
});

MongoDB in Node.js

// test/mongo.integration.spec.ts
import { MongoDBContainer, StartedMongoDBContainer } from 'testcontainers';
import { MongoClient, Db } from 'mongodb';

describe('MongoDB integration', () => {
  let container: StartedMongoDBContainer;
  let client: MongoClient;
  let db: Db;

  beforeAll(async () => {
    container = await new MongoDBContainer('mongo:7').start();

    client = new MongoClient(container.getConnectionString(), {
      directConnection: true,
    });
    await client.connect();
    db = client.db('testdb');
  }, 60_000);

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

  beforeEach(async () => {
    await db.dropDatabase();
  });

  it('inserts and queries a document', async () => {
    const col = db.collection('products');
    await col.insertOne({ name: 'Widget', price: 9.99, tags: ['new'] });

    const doc = await col.findOne({ name: 'Widget' });
    expect(doc).not.toBeNull();
    expect(doc!.price).toBe(9.99);
  });

  it('queries by array field membership', async () => {
    const col = db.collection('products');
    await col.insertMany([
      { name: 'A', tags: ['sale', 'new'] },
      { name: 'B', tags: ['clearance'] },
    ]);

    const onSale = await col.find({ tags: 'sale' }).toArray();
    expect(onSale).toHaveLength(1);
    expect(onSale[0].name).toBe('A');
  });
});

Parallel Container Execution in Node.js

When a test file needs multiple databases, starting them sequentially wastes time. Use Promise.all to start them concurrently:

// test/utils/startAllContainers.ts
import { PostgreSqlContainer, RedisContainer } from 'testcontainers';

export async function startAllContainers() {
  const [postgresContainer, redisContainer] = await Promise.all([
    new PostgreSqlContainer('postgres:16-alpine').start(),
    new RedisContainer('redis:7-alpine').start(),
  ]);

  return { postgresContainer, redisContainer };
}

Three containers started in parallel saves roughly 2× the wall-clock time of the slowest container versus starting each sequentially.

Python Setup

Install testcontainers with the modules you need:

pip install testcontainers[postgres,redis,mongodb]

PostgreSQL in Python

# tests/test_postgres.py
import pytest
import psycopg2
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="module")
def postgres_connection():
    with PostgresContainer("postgres:16-alpine") as postgres:
        conn = psycopg2.connect(postgres.get_connection_url())
        conn.autocommit = True
        cur = conn.cursor()
        cur.execute("""
            CREATE TABLE products (
                id SERIAL PRIMARY KEY,
                name TEXT NOT NULL,
                price NUMERIC(10,2)
            )
        """)
        yield conn
        conn.close()

@pytest.fixture(autouse=True)
def clear_products(postgres_connection):
    yield
    cur = postgres_connection.cursor()
    cur.execute("TRUNCATE products RESTART IDENTITY")

def test_insert_and_retrieve(postgres_connection):
    cur = postgres_connection.cursor()
    cur.execute("INSERT INTO products (name, price) VALUES (%s, %s)", ("Gadget", 19.99))

    cur.execute("SELECT name, price FROM products WHERE name = %s", ("Gadget",))
    row = cur.fetchone()

    assert row is not None
    assert row[0] == "Gadget"
    assert float(row[1]) == 19.99

def test_price_constraint(postgres_connection):
    cur = postgres_connection.cursor()
    with pytest.raises(Exception):
        # name has NOT NULL constraint
        cur.execute("INSERT INTO products (name) VALUES (NULL)")

Redis in Python

# tests/test_redis.py
import pytest
import redis
from testcontainers.redis import RedisContainer

@pytest.fixture(scope="module")
def redis_client():
    with RedisContainer("redis:7-alpine") as redis_container:
        client = redis.Redis(
            host=redis_container.get_container_host_ip(),
            port=redis_container.get_exposed_port(6379),
            decode_responses=True,
        )
        yield client

@pytest.fixture(autouse=True)
def flush_redis(redis_client):
    yield
    redis_client.flushall()

def test_set_and_get(redis_client):
    redis_client.set("key:1", "hello")
    assert redis_client.get("key:1") == "hello"

def test_hash_operations(redis_client):
    redis_client.hset("user:1", mapping={"name": "Alice", "role": "admin"})
    data = redis_client.hgetall("user:1")
    assert data["name"] == "Alice"
    assert data["role"] == "admin"

def test_list_push_pop(redis_client):
    redis_client.rpush("queue", "task1", "task2", "task3")
    first = redis_client.lpop("queue")
    assert first == "task1"
    assert redis_client.llen("queue") == 2

Parallel Container Execution in Python

Python's asyncio enables concurrent container startup:

# tests/conftest.py
import asyncio
import pytest
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer

def start_postgres():
    container = PostgresContainer("postgres:16-alpine")
    container.start()
    return container

def start_redis():
    container = RedisContainer("redis:7-alpine")
    container.start()
    return container

@pytest.fixture(scope="session")
def all_containers():
    loop = asyncio.new_event_loop()

    async def start_all():
        return await asyncio.gather(
            loop.run_in_executor(None, start_postgres),
            loop.run_in_executor(None, start_redis),
        )

    pg_container, redis_container = loop.run_until_complete(start_all())

    yield {"postgres": pg_container, "redis": redis_container}

    pg_container.stop()
    redis_container.stop()
    loop.close()

GitHub Actions CI Integration

Both Node.js and Python Testcontainers work on ubuntu-latest runners without any special Docker service configuration:

# .github/workflows/integration-tests.yaml
name: Integration Tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test-node:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Pull container images (cache warm-up)
        run: |
          docker pull postgres:16-alpine &
          docker pull redis:7-alpine &
          docker pull mongo:7 &
          wait

      - run: npm ci

      - run: npm test

  test-python:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'

      - name: Pull container images
        run: |
          docker pull postgres:16-alpine &
          docker pull redis:7-alpine &
          wait

      - run: pip install -r requirements-dev.txt

      - run: pytest tests/ -v --tb=short

The parallel docker pull lines in CI warm the layer cache before tests run, making container startup time deterministic regardless of Docker Hub rate limits during the test run itself.

Performance Tips for Faster Startup

Use Alpine variantspostgres:16-alpine is 65MB compressed versus 155MB for the Debian variant. Smaller images pull and start faster.

Keep containers alive for the entire suite with scope="module" (Python) or beforeAll (Node.js) — the single biggest performance win is not restarting containers between tests. Truncate data instead.

Use withReuse() in Node.js for local development — this flag re-uses an already-running container across test runs:

const container = await new PostgreSqlContainer('postgres:16-alpine')
  .withReuse()
  .start();

On first run it starts the container; on subsequent runs in the same session it reattaches. This eliminates the 5-10 second startup cost on every npm test invocation locally.

Pre-warm images in CI as a separate step — pulling images before tests start, in parallel using shell &, keeps container startup from adding unpredictable time to individual test steps.

Cleanup Hooks

Testcontainers registers shutdown hooks through Ryuk, but explicitly calling container.stop() in afterAll / fixture teardown is safer:

// Node.js — always explicit
afterAll(async () => {
  await pool.end();          // close connections first
  await container.stop();    // then stop container
});
# Python — context manager handles it
with PostgresContainer("postgres:16-alpine") as pg:
    # tests run here
    pass
# container stopped automatically on context exit

Using context managers in Python is the most reliable approach because teardown happens even if an exception escapes the test setup.

Conclusion

Testcontainers removes the most common excuse for mocking databases in integration tests: setup complexity. A real Postgres container starts in under 10 seconds on a warm cache. A real Redis container starts in under 3 seconds. The investment pays off immediately — bugs that mock clients would have silently passed become loud failures in CI, before they reach production.

Start with a single beforeAll container per test file, parallel-start multiple containers when you need them, and pre-pull images in your CI workflow. These three practices give you production-equivalent database behavior with a CI pipeline that stays practical to run on every pull request.

Read more