Bulletproof Integration Testing in Node.js: Spin Up Real Databases with Testcontainers and Vitest
Learn how to write robust end-to-end integration tests in Node.js using Vitest, Supertest, and Testcontainers to spin up isolated PostgreSQL and Redis instances on the fly.
Bulletproof Integration Testing in Node.js: Spin Up Real Databases with Testcontainers and Vitest
Mocking databases in Node.js unit tests gives us a false sense of security. Mocks don’t throw foreign key constraint violations, they don’t test complex SQL indexing behavior, and they certainly don’t validate whether your Redis serialization logic will survive a real network round-trip.
When we rely solely on mocks, we often discover production bugs that our test suite happily green-lit. To achieve true confidence, we need End-to-End (E2E) integration testing—testing our actual Express or Fastify application against real databases.
In this guide, we will build a robust integration testing pipeline in Node.js using:
- Vitest: A blazing-fast, Vite-native testing framework with native TypeScript support.
- Supertest: For high-level HTTP assertions against our Node.js app.
- Testcontainers: To programmatically spin up ephemeral Docker containers for PostgreSQL and Redis during our test lifecycle.
The Problem with Traditional Integration Tests
Traditionally, integration testing required a shared development database or a complex Docker Compose setup running locally or in a CI pipeline. This approach introduces major friction points:
- State Pollution: Tests step on each other’s toes, modifying shared rows and causing flaky failures.
- Environment Drift: CI environments behave differently than local developer machines.
- Cleanup Overhead: Writing complex teardown and rollback scripts that inevitably fail or leave dangling records.
Testcontainers solves this by treating infrastructure as code. Every test suite (or test file) spins up its own isolated database container, runs tests against it, and destroys it completely upon completion.
Project Architecture & Dependencies
Let’s set up a modern Node.js backend using TypeScript, Express, Drizzle ORM (for PostgreSQL), and ioredis.
Installation
Initialize your project and install the core production and testing dependencies:
npm init -y
npm pkg set type="module"
npm install express pg drizzle-orm ioredis dotenv
npm install -D typescript @types/express @types/node vitest supertest testcontainers @types/supertest tsx
Ensure your tsconfig.json is configured for modern Node.js module resolution:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Building the App Infrastructure
To make our application testable, we must separate the database connection and Express server initialization from the entry point (server.ts). This allows our test runner to spin up instances with dynamic connection strings.
1. Database Configuration (src/db.ts)
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
export let db: ReturnType<typeof drizzle<typeof schema>>;
export let pool: pg.Pool;
export async function connectDatabase(connectionString: string) {
pool = new pg.Pool({ connectionString });
db = drizzle(pool, { schema });
await pool.query('SELECT 1'); // Test connection
}
export async function disconnectDatabase() {
if (pool) {
await pool.end();
}
}
2. Database Schema (src/schema.ts)
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
export const usersTable = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
3. Express App (src/app.ts)
import express, { Express, Request, Response } from 'express';
import { db } from './db.js';
import { usersTable } from './schema.js';
import { eq } from 'drizzle-orm';
export function createApp(): Express {
const app = express();
app.use(express.json());
app.post('/users', async (req: Request, res: Response) => {
try {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
const [newUser] = await db.insert(usersTable).values({ name, email }).returning();
return res.status(201).json(newUser);
} catch (error: any) {
if (error.code === '23505') { // Unique violation in Postgres
return res.status(409).json({ error: 'Email already exists' });
}
return res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/users/:id', async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
return res.json(user);
});
return app;
}
Setting Up Testcontainers for Vitest
Now comes the core of our strategy. We will create a test setup utility that spins up a PostgreSQL container and a Redis container before our tests run, injects their connection strings into the environment, and tears them down gracefully.
Creating the Test Lifecycle Helper (test/setup.ts)
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { connectDatabase, disconnectDatabase, pool } from '../src/db.js';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { drizzle } from 'drizzle-orm/node-postgres';
import { beforeAll, afterAll, beforeEach } from 'vitest';
let postgresContainer: StartedPostgreSqlContainer;
let redisContainer: StartedTestContainer;
beforeAll(async () => {
// 1. Spin up PostgreSQL Container
postgresContainer = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('test_db')
.withUsername('test_user')
.withPassword('test_password')
.start();
const pgConnectionString = postgresContainer.getConnectionUri();
process.env.DATABASE_URL = pgConnectionString;
// 2. Spin up Redis Container
redisContainer = await new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.start();
const redisHost = redisContainer.getHost();
const redisPort = redisContainer.getMappedPort(6379);
process.env.REDIS_URL = `redis://${redisHost}:${redisPort}`;
// 3. Connect and run migrations
await connectDatabase(pgConnectionString);
// Run raw SQL migration to match schema for test simplicity
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW() NOT NULL
);
`);
}, 60000); // 60-second timeout for container startup on cold Docker runs
afterAll(async () => {
await disconnectDatabase();
if (postgresContainer) await postgresContainer.stop();
if (redisContainer) await redisContainer.stop();
});
// Reset database state between tests
beforeEach(async () => {
await pool.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE;');
});
Configuring Vitest (vitest.config.ts)
Point Vitest to our setup file:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./test/setup.ts'],
testTimeout: 30000,
},
});
Writing the E2E Integration Tests
With our containers spinning up seamlessly behind the scenes, writing Supertest assertions is clean, fast, and mirrors actual HTTP traffic hitting our endpoints.
Writing test/users.e2e-spec.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app.js';
const app = createApp();
describe('Users API (E2E)', () => {
it('should successfully create a new user', async () => {
const response = await request(app)
.post('/users')
.send({
name: 'Jane Doe',
email: 'jane.doe@example.com',
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: 1,
name: 'Jane Doe',
email: 'jane.doe@example.com',
});
expect(response.body).toHaveProperty('createdAt');
});
it('should return 400 if required fields are missing', async () => {
const response = await request(app)
.post('/users')
.send({
name: 'Incomplete User',
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error', 'Name and email are required');
});
it('should enforce unique email constraint at the database level', async () => {
const userData = { name: 'Alice', email: 'alice@example.com' };
// Create first user
await request(app).post('/users').send(userData);
// Attempt duplicate creation
const response = await request(app).post('/users').send(userData);
expect(response.status).toBe(409);
expect(response.body).toHaveProperty('error', 'Email already exists');
});
it('should retrieve an existing user by ID', async () => {
// Seed user
const createRes = await request(app)
.post('/users')
.send({ name: 'Bob Smith', email: 'bob@example.com' });
const userId = createRes.body.id;
// Fetch user
const getRes = await request(app).get(`/users/${userId}`);
expect(getRes.status).toBe(200);
expect(getRes.body.name).toBe('Bob Smith');
});
it('should return 404 for a non-existent user', async () => {
const response = await request(app).get('/users/9999');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error', 'User not found');
});
});
Running the Test Suite
Execute your test suite using npm:
npx vitest run
Behind the scenes, Testcontainers will:
- Check if Docker daemon is running.
- Pull the required
postgres:16-alpineandredis:7-alpineimages (if not already cached). - Instantiate ephemeral containers with randomized host ports.
- Run your Express application tests against isolated database instances.
- Tear down containers immediately upon test termination.
Tip for CI/CD Pipelines: Ensure your GitHub Actions workflow or GitLab CI runner has Docker-in-Docker (DinD) enabled or socket mounting configured so Testcontainers can spawn child containers successfully.
Best Practices & Performance Optimization
- Container Reuse: For massive test suites, look into Testcontainers’
.withReuse()feature to keep containers alive across local test runs, drastically speeding up feedback loops. - Fast Truncation: Use
TRUNCATE ... RESTART IDENTITY CASCADEinbeforeEachhooks rather than dropping and recreating schemas to keep execution speeds under milliseconds per test. - Parallel Test Isolation: If running tests in parallel files, ensure your database schema migrations are idempotent and consider isolating state by namespace or running tests sequentially if shared schema mutations conflict.
Conclusion
By combining Vitest, Supertest, and Testcontainers, we bridge the gap between fragile unit tests and brittle production environments. You no longer need to guess whether your SQL queries work or whether your database constraints hold up under pressure. You test against the real thing—every single time.