PostgreSQL Testing with Testcontainers and pg-mem: A Complete Guide

PostgreSQL Testing with Testcontainers and pg-mem: A Complete Guide

PostgreSQL is the world's most popular open-source relational database — and testing PostgreSQL-backed applications is one of the most important skills a backend engineer can have. This guide covers the full spectrum: fast in-memory tests with pg-mem, realistic integration tests with Testcontainers, and patterns that scale to production complexity.

The Testing Spectrum for PostgreSQL

Not all PostgreSQL tests need a real database. Understanding where each approach fits saves time and catches more bugs:

Approach Speed Fidelity Best For
pg-mem (in-memory) Fastest ~80% Unit tests, query logic
Testcontainers (real PG) Moderate 100% Integration tests, migrations
Shared test DB Fast 100% Local dev, CI
Production clone Slow 100% Performance, data-specific bugs

In-Memory Testing with pg-mem

pg-mem is a TypeScript/JavaScript in-memory PostgreSQL emulator. It supports most SQL — SELECT, INSERT, UPDATE, DELETE, JOINs, CTEs, window functions, triggers, and more — without starting a real database.

Installation

npm install pg-mem

Basic Setup

import { newDb } from 'pg-mem';

const db = newDb();

// Get a client compatible with the `pg` library interface
const client = db.adapters.createPg();

async function setupSchema(client) {
  await client.query(`
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      email TEXT UNIQUE NOT NULL,
      name TEXT NOT NULL,
      created_at TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE TABLE orders (
      id SERIAL PRIMARY KEY,
      user_id INTEGER REFERENCES users(id),
      total DECIMAL(10,2) NOT NULL,
      status TEXT DEFAULT 'pending'
    );
  `);
}

Testing Repository Classes

import { newDb } from 'pg-mem';
import { UserRepository } from '../src/repositories/user';

describe('UserRepository', () => {
  let db, pool, repo;
  
  beforeEach(async () => {
    db = newDb();
    pool = db.adapters.createPgPool();
    await setupSchema(pool);
    repo = new UserRepository(pool);
  });
  
  afterEach(() => {
    pool.end();
  });
  
  test('creates a user', async () => {
    const user = await repo.create({
      email: 'alice@example.com',
      name: 'Alice'
    });
    
    expect(user.id).toBeDefined();
    expect(user.email).toBe('alice@example.com');
    expect(user.created_at).toBeDefined();
  });
  
  test('enforces unique email constraint', async () => {
    await repo.create({ email: 'dup@example.com', name: 'First' });
    
    await expect(
      repo.create({ email: 'dup@example.com', name: 'Second' })
    ).rejects.toThrow(/unique/i);
  });
  
  test('finds users by partial name match', async () => {
    await repo.create({ email: 'alice@example.com', name: 'Alice Smith' });
    await repo.create({ email: 'bob@example.com', name: 'Bob Jones' });
    await repo.create({ email: 'alex@example.com', name: 'Alex Smith' });
    
    const smiths = await repo.findByName('Smith');
    expect(smiths).toHaveLength(2);
    expect(smiths.map(u => u.email)).toContain('alice@example.com');
  });
  
  test('handles complex aggregation query', async () => {
    const user = await repo.create({ email: 'rich@example.com', name: 'Rich' });
    
    await pool.query(`
      INSERT INTO orders (user_id, total, status) VALUES
        ($1, 99.99, 'completed'),
        ($1, 49.50, 'completed'),
        ($1, 25.00, 'pending')
    `, [user.id]);
    
    const stats = await repo.getOrderStats(user.id);
    expect(stats.total_completed).toBe('149.49');
    expect(stats.order_count).toBe(2);
  });
});

pg-mem Limitations

pg-mem doesn't support everything. Know the gaps:

  • Extensions: pg_trgm, postgis, uuid-ossp, and others are not supported (workarounds exist for some)
  • Full-text search: Basic tsvector/tsquery support only
  • Custom functions: CREATE FUNCTION has limited support
  • EXPLAIN/ANALYZE: Not supported
  • Stored procedures: Partial support

For anything involving extensions or complex PL/pgSQL, use Testcontainers instead.

Integration Testing with Testcontainers

Testcontainers spins up a real PostgreSQL container for your tests — same binary, same behavior as production.

Python Setup

pip install testcontainers[postgres] psycopg2-binary pytest
import pytest
import psycopg2
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def postgres_container():
    with PostgresContainer("postgres:16-alpine") as container:
        yield container

@pytest.fixture(scope="session")  
def db_connection(postgres_container):
    conn = psycopg2.connect(postgres_container.get_connection_url())
    yield conn
    conn.close()

@pytest.fixture(scope="function")
def db(db_connection):
    """Wrap each test in a transaction that gets rolled back"""
    cursor = db_connection.cursor()
    db_connection.autocommit = False
    
    yield cursor
    
    db_connection.rollback()  # Rollback after each test
    cursor.close()

Transaction-Based Test Isolation

The most powerful pattern for database test isolation is wrapping each test in a transaction and rolling back:

def test_user_creation(db):
    db.execute("""
        INSERT INTO users (email, name) 
        VALUES ('test@example.com', 'Test User')
        RETURNING id, email, name
    """)
    user = db.fetchone()
    
    assert user['email'] == 'test@example.com'
    # Transaction is rolled back after this test
    # Next test starts with a clean slate

def test_foreign_key_constraint(db):
    # This test doesn't see the user from the previous test
    with pytest.raises(psycopg2.IntegrityError):
        db.execute("""
            INSERT INTO orders (user_id, total) 
            VALUES (99999, 100.00)
        """)

Node.js Testcontainers Setup

const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Pool } = require('pg');

describe('Order Service Integration', () => {
  let container, pool;
  
  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:16-alpine')
      .withDatabase('testdb')
      .withUsername('testuser')
      .withPassword('testpass')
      .start();
    
    pool = new Pool({
      connectionString: container.getConnectionUri()
    });
    
    // Run migrations
    await runMigrations(pool);
  }, 60000); // 60s timeout for container startup
  
  afterAll(async () => {
    await pool.end();
    await container.stop();
  });
  
  beforeEach(async () => {
    await pool.query('BEGIN');
  });
  
  afterEach(async () => {
    await pool.query('ROLLBACK');
  });
  
  test('calculates order total with discounts', async () => {
    // Insert test data
    const { rows: [user] } = await pool.query(
      'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
      ['buyer@test.com', 'Test Buyer']
    );
    
    await pool.query(
      'INSERT INTO orders (user_id, total, discount_pct) VALUES ($1, $2, $3)',
      [user.id, 100.00, 10]
    );
    
    const { rows: [order] } = await pool.query(
      'SELECT calculate_final_total(id) as final FROM orders WHERE user_id = $1',
      [user.id]
    );
    
    expect(parseFloat(order.final)).toBe(90.00);
  });
});

Testing with Extensions

One major advantage of Testcontainers over pg-mem is full extension support:

@pytest.fixture(scope="session")
def postgres_with_extensions():
    with PostgresContainer("postgres:16-alpine") as container:
        conn = psycopg2.connect(container.get_connection_url())
        cur = conn.cursor()
        cur.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
        cur.execute("CREATE EXTENSION IF NOT EXISTS uuid-ossp")
        conn.commit()
        yield conn
        conn.close()

def test_trigram_similarity_search(db_with_extensions):
    db_with_extensions.execute("""
        CREATE INDEX IF NOT EXISTS products_name_trgm 
        ON products USING gin (name gin_trgm_ops)
    """)
    
    db_with_extensions.execute("""
        SELECT name, similarity(name, 'PostgresQL') as sim
        FROM products
        WHERE name % 'PostgresQL'
        ORDER BY sim DESC
        LIMIT 5
    """)
    
    results = db_with_extensions.fetchall()
    assert len(results) > 0
    assert results[0]['sim'] > 0.3

Testing with Prisma ORM

import { PrismaClient } from '@prisma/client';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';

let prisma: PrismaClient;
let container: StartedPostgreSqlContainer;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16-alpine').start();
  
  process.env.DATABASE_URL = container.getConnectionUri();
  
  // Run Prisma migrations
  execSync('npx prisma migrate deploy', {
    env: { ...process.env, DATABASE_URL: container.getConnectionUri() }
  });
  
  prisma = new PrismaClient({
    datasources: { db: { url: container.getConnectionUri() } }
  });
});

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

test('creates user with correct defaults', async () => {
  const user = await prisma.user.create({
    data: { email: 'prisma@test.com', name: 'Prisma User' }
  });
  
  expect(user.id).toMatch(/^[0-9a-f-]{36}$/); // UUID
  expect(user.createdAt).toBeInstanceOf(Date);
  expect(user.role).toBe('USER'); // Default value
});

CI/CD Configuration

GitHub Actions

name: Database Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      # Testcontainers handles Docker automatically in GitHub Actions
      - name: Run integration tests
        run: npm test
        env:
          DOCKER_HOST: unix:///var/run/docker.sock

For Python with the service container approach (faster than Testcontainers in CI):

services:
  postgres:
    image: postgres:16-alpine
    env:
      POSTGRES_PASSWORD: testpass
      POSTGRES_DB: testdb
    ports:
      - 5432:5432
    options: >-
      --health-cmd pg_isready
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5

steps:
  - name: Run tests
    run: pytest tests/
    env:
      DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb

Monitoring PostgreSQL in Production

Tests guard against regressions, but production PostgreSQL has runtime failure modes that tests can't catch:

  • Connection pool exhaustion under load
  • Slow queries after data growth changes query plans
  • Replication lag causing stale reads
  • Failed migrations that partially applied

HelpMeTest runs continuous end-to-end tests against your live application, verifying database-backed features work correctly 24/7. When a PostgreSQL issue causes an API endpoint to fail or return wrong data, you're alerted immediately — before users notice. Write tests in plain English, no code required.

Summary

Use pg-mem for fast unit tests of query logic and repository patterns — it needs no Docker and runs in milliseconds. Use Testcontainers for integration tests that need real PostgreSQL behavior, extension support, or migration testing. Combine transaction-based isolation for clean test state without truncation overhead. And always monitor production to catch the runtime failures that no amount of testing will predict.

Read more