Testcontainers for Node.js: Database Integration Tests Done Right

Testcontainers for Node.js: Database Integration Tests Done Right

Node.js integration tests that mock PostgreSQL and Redis test your assumptions, not your code. testcontainers starts real database containers during your Jest run and tears them down after. Your tests talk to the actual services — no mocks, no drift, no surprises.

Key Takeaways

testcontainers/node works with Jest, Vitest, and Node's built-in test runner. Use Jest's globalSetup and globalTeardown to manage container lifecycle efficiently across your test suite.

Containers start once per test run, not once per test. Use Jest's globalSetup to start containers and store connection strings in environment variables that test files read.

Works with Prisma, Knex, Sequelize, and raw pg. The container gives you a real connection URL — use it with any database client.

NestJS testing module integrates cleanly. Pass container connection details into the NestJS testing module to override providers with real services.

Installation

npm install --save-dev testcontainers
# For TypeScript projects
npm install --save-dev @types/node

You need Docker running locally and in CI.

The Jest Global Setup Pattern

The key to fast Node.js integration tests: start containers once for the entire Jest run, not per-file or per-test.

// jest.config.js
module.exports = {
  globalSetup: './tests/setup.js',
  globalTeardown: './tests/teardown.js',
  testEnvironment: 'node',
  testMatch: ['**/tests/**/*.test.js'],
};
// tests/setup.js
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { RedisContainer } = require('@testcontainers/redis');

module.exports = async () => {
  // Start PostgreSQL
  const postgres = await new PostgreSqlContainer('postgres:15-alpine')
    .withDatabase('testdb')
    .withUsername('testuser')
    .withPassword('testpass')
    .start();

  // Start Redis  
  const redis = await new RedisContainer('redis:7-alpine').start();

  // Store connection details in environment variables
  // (globalSetup runs in a separate process — use env vars to share with tests)
  process.env.TEST_DATABASE_URL = postgres.getConnectionUri();
  process.env.TEST_REDIS_URL = `redis://${redis.getHost()}:${redis.getMappedPort(6379)}`;

  // Store container references for teardown
  global.__POSTGRES_CONTAINER__ = postgres;
  global.__REDIS_CONTAINER__ = redis;
};
// tests/teardown.js
module.exports = async () => {
  await global.__POSTGRES_CONTAINER__?.stop();
  await global.__REDIS_CONTAINER__?.stop();
};

Writing Integration Tests

With containers running via global setup, your tests are straightforward:

// tests/user-repository.test.js
const { Pool } = require('pg');
const { UserRepository } = require('../src/repositories/user');

let pool;
let repo;

beforeAll(async () => {
  pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
  
  // Run schema
  await pool.query(`
    CREATE TABLE IF NOT EXISTS users (
      id SERIAL PRIMARY KEY,
      email VARCHAR(255) UNIQUE NOT NULL,
      name VARCHAR(255) NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )
  `);
  
  repo = new UserRepository(pool);
});

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

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

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');
});

test('finds user by email', async () => {
  await repo.create({ email: 'bob@example.com', name: 'Bob' });
  
  const found = await repo.findByEmail('bob@example.com');
  expect(found.name).toBe('Bob');
});

test('returns null for missing user', async () => {
  const found = await repo.findByEmail('nobody@example.com');
  expect(found).toBeNull();
});

test('throws on duplicate email', async () => {
  await repo.create({ email: 'dup@example.com', name: 'First' });
  
  await expect(
    repo.create({ email: 'dup@example.com', name: 'Second' })
  ).rejects.toThrow();
});

Prisma Integration

For projects using Prisma:

// tests/setup.js
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { execSync } = require('child_process');

module.exports = async () => {
  const postgres = await new PostgreSqlContainer('postgres:15-alpine').start();
  
  process.env.DATABASE_URL = postgres.getConnectionUri();
  
  // Run Prisma migrations against the test database
  execSync('npx prisma migrate deploy', {
    env: { ...process.env, DATABASE_URL: postgres.getConnectionUri() },
  });
  
  global.__POSTGRES_CONTAINER__ = postgres;
};
// tests/user.test.js
const { PrismaClient } = require('@prisma/client');

let prisma;

beforeAll(() => {
  prisma = new PrismaClient({
    datasources: { db: { url: process.env.DATABASE_URL } },
  });
});

afterAll(() => prisma.$disconnect());

beforeEach(() => prisma.user.deleteMany());

test('creates and retrieves a user', async () => {
  const created = await prisma.user.create({
    data: { email: 'test@example.com', name: 'Test User' },
  });
  
  const found = await prisma.user.findUnique({
    where: { email: 'test@example.com' },
  });
  
  expect(found.id).toBe(created.id);
  expect(found.name).toBe('Test User');
});

NestJS Testing Module

NestJS's dependency injection makes it easy to substitute real containers for test providers:

// tests/app.integration.spec.ts
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { UsersModule } from '../src/users/users.module';
import { UsersService } from '../src/users/users.service';

describe('UsersService Integration', () => {
  let container: StartedPostgreSqlContainer;
  let service: UsersService;

  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:15-alpine').start();

    const module = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'postgres',
          url: container.getConnectionUri(),
          autoLoadEntities: true,
          synchronize: true,  // OK for tests
        }),
        UsersModule,
      ],
    }).compile();

    const app = module.createNestApplication();
    await app.init();

    service = module.get<UsersService>(UsersService);
  });

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

  it('creates and retrieves a user', async () => {
    const user = await service.create({ email: 'alice@example.com', name: 'Alice' });
    
    expect(user.id).toBeDefined();
    
    const found = await service.findByEmail('alice@example.com');
    expect(found.name).toBe('Alice');
  });
});

Redis Integration Tests

// tests/cache.test.js
const Redis = require('ioredis');
const { CacheService } = require('../src/services/cache');

let redis;
let cache;

beforeAll(() => {
  redis = new Redis(process.env.TEST_REDIS_URL);
  cache = new CacheService(redis);
});

afterAll(() => redis.quit());

beforeEach(() => redis.flushall());

test('returns null for cache miss', async () => {
  const result = await cache.get('missing-key');
  expect(result).toBeNull();
});

test('stores and retrieves a value', async () => {
  await cache.set('session:abc', { userId: 1 }, 300);
  
  const value = await cache.get('session:abc');
  expect(value.userId).toBe(1);
});

test('respects TTL expiry', async () => {
  await cache.set('short-lived', 'value', 1);
  
  await new Promise(resolve => setTimeout(resolve, 1100));
  
  const result = await cache.get('short-lived');
  expect(result).toBeNull();
});

GitHub Actions CI

name: Node.js Integration Tests
on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci
      
      - name: Run integration tests
        run: npm run test:integration
        # Docker available on ubuntu-latest — Testcontainers works without extra setup
      
      - name: Upload coverage
        if: always()
        uses: codecov/codecov-action@v4

Your package.json:

{
  "scripts": {
    "test": "jest --testPathPattern=unit",
    "test:integration": "jest --testPathPattern=integration --runInBand"
  }
}

--runInBand runs integration tests serially — important when multiple test files share the same containers via global setup.

Vitest Alternative

If you're using Vitest instead of Jest:

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

export default defineConfig({
  test: {
    globalSetup: './tests/setup.ts',
    pool: 'forks',  // Required for global setup to share env vars
  },
});

The setup file is identical — Vitest supports the same globalSetup/globalTeardown pattern.

Keeping Tests Fast

The global setup approach starts containers once for the entire test suite. For a typical app with PostgreSQL and Redis, expect 5-10 seconds of overhead at the start, then near-zero overhead per test.

If your test suite grows large, consider splitting into multiple Jest projects (each with their own globalSetup) and running them in parallel. Each project gets its own containers — no conflicts.

What to Test Next

Testcontainers covers your data layer. But once your application serves real users, you need tests that verify their experience — not just your database queries.

HelpMeTest adds end-to-end browser testing on top: test the full user journey from login to checkout in a real browser, against your deployed application. Free plan covers up to 10 tests with 24/7 monitoring.

Read more