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/nodeYou 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@v4Your 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.