- Published on
Node.js Testing in 2026 — Vitest, TestContainers, and Testing Without Mocking Everything
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Jest dominates but struggles with ESM, is slower, and encourages over-mocking. Vitest is faster and modern. Combined with real TestContainers databases, MSW for HTTP mocking, and Pact for contracts, you build confidence in actual behavior, not mocked stubs. This post covers testing patterns that catch production bugs.
- Vitest Over Jest: ESM, Speed, and Modern Tooling
- Integration Tests with Real Databases via TestContainers
- MSW: Mock Service Worker for HTTP Mocking
- Test Isolation: Transaction Rollback and Truncate
- Contract Testing with Pact
- Snapshot Testing for API Responses
- Coverage Thresholds as CI Gates
- Checklist
- Conclusion
Vitest Over Jest: ESM, Speed, and Modern Tooling
Vitest is Jest-compatible but faster, supports ESM natively, and has better debugging.
# Install
npm install -D vitest @vitest/ui
# Run
vitest
# UI dashboard
vitest --ui
# Single run
vitest run
# Coverage
vitest run --coverage
// math.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('Math operations', () => {
let state: { value: number };
beforeEach(() => {
state = { value: 0 };
});
afterEach(() => {
console.log('Cleanup after test');
});
it('should add numbers', () => {
const result = 2 + 3;
expect(result).toBe(5);
});
it('should handle edge cases', () => {
expect(Number.isNaN(NaN)).toBe(true);
expect(1 / 0).toBe(Infinity);
});
it('should run async operations', async () => {
const result = await Promise.resolve(42);
expect(result).toBe(42);
});
});
Why Vitest > Jest:
// ESM support (native, not transpiled)
import { add } from './math.js'; // Works with Vitest, slower with Jest
// Speed: Vitest is 5-10x faster
// - Vite's instant module resolution
// - Parallel execution by default
// - Instant feedback in watch mode
// Debugging: native debugger support
// vitest run --inspect-brk
// Module mocking (vi instead of jest)
import { vi, describe, it, expect } from 'vitest';
describe('with mocking', () => {
it('should mock modules', () => {
const mockFn = vi.fn().mockReturnValue(42);
expect(mockFn()).toBe(42);
expect(mockFn).toHaveBeenCalled();
});
it('should mock imports', async () => {
const mod = await import('./module.js');
vi.spyOn(mod, 'myFunction').mockReturnValue('mocked');
});
});
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // describe, it, expect without imports
environment: 'node', // or 'jsdom' for DOM testing
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
exclude: ['node_modules/', 'dist/'],
},
testTimeout: 10000,
hookTimeout: 10000,
},
});
Integration Tests with Real Databases via TestContainers
Don't mock databases—run real ones in Docker containers.
npm install -D @testcontainers/testcontainers
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { GenericContainer, StartedTestContainer } from '@testcontainers/testcontainers';
import pg from 'pg';
describe('User repository with real database', () => {
let container: StartedTestContainer;
let pool: pg.Pool;
beforeAll(async () => {
// Start PostgreSQL container
container = await new GenericContainer('postgres:16-alpine')
.withEnvironment({
POSTGRES_USER: 'test',
POSTGRES_PASSWORD: 'test',
POSTGRES_DB: 'testdb',
})
.withExposedPorts(5432)
.withWaitStrategy({
strategy: 'HealthCheck',
})
.start();
const host = container.getHost();
const port = container.getMappedPort(5432);
// Connect to real database
pool = new pg.Pool({
host,
port,
user: 'test',
password: 'test',
database: 'testdb',
});
// Create tables
await pool.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
)
`);
});
afterAll(async () => {
await pool.end();
await container.stop();
});
it('should insert and retrieve user', async () => {
// Insert
const result = await pool.query(
'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
['alice@example.com', 'Alice']
);
const user = result.rows[0];
expect(user.email).toBe('alice@example.com');
expect(user.name).toBe('Alice');
// Retrieve
const retrieved = await pool.query(
'SELECT * FROM users WHERE id = $1',
[user.id]
);
expect(retrieved.rows[0]).toEqual(user);
});
it('should enforce unique constraint', async () => {
// Insert first user
await pool.query(
'INSERT INTO users (email, name) VALUES ($1, $2)',
['bob@example.com', 'Bob']
);
// Try duplicate email
try {
await pool.query(
'INSERT INTO users (email, name) VALUES ($1, $2)',
['bob@example.com', 'Bob 2']
);
expect.fail('Should throw on duplicate email');
} catch (err: any) {
expect(err.code).toBe('23505'); // Unique violation
}
});
it('should handle transactions', async () => {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
'INSERT INTO users (email, name) VALUES ($1, $2)',
['charlie@example.com', 'Charlie']
);
// Rollback for this test
await client.query('ROLLBACK');
const result = await pool.query('SELECT * FROM users WHERE email = $1', [
'charlie@example.com',
]);
expect(result.rows).toHaveLength(0); // No insert due to rollback
} finally {
client.release();
}
});
});
// Similar patterns for Redis, MySQL, MongoDB
describe('With Redis', () => {
beforeAll(async () => {
container = await new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.start();
});
});
MSW: Mock Service Worker for HTTP Mocking
Instead of mocking fetch/axios, mock at the network level.
npm install -D msw
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// Define handlers
const handlers = [
http.get('https://api.example.com/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({ id, name: `User ${id}` });
}),
http.post('https://api.example.com/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 1, ...body },
{ status: 201 }
);
}),
http.get('https://api.example.com/error', () => {
return HttpResponse.json(
{ error: 'Internal error' },
{ status: 500 }
);
}),
];
const server = setupServer(...handlers);
describe('API client', () => {
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
it('should fetch user', async () => {
const response = await fetch('https://api.example.com/users/1');
const data = await response.json();
expect(data).toEqual({ id: '1', name: 'User 1' });
});
it('should create user', async () => {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
});
expect(response.status).toBe(201);
const data = await response.json();
expect(data.name).toBe('Alice');
});
it('should handle errors', async () => {
const response = await fetch('https://api.example.com/error');
expect(response.status).toBe(500);
});
it('should override handler per-test', async () => {
// Override for this test only
server.use(
http.get('https://api.example.com/users/:id', () => {
return HttpResponse.json({ error: 'Not found' }, { status: 404 });
})
);
const response = await fetch('https://api.example.com/users/999');
expect(response.status).toBe(404);
});
});
Test Isolation: Transaction Rollback and Truncate
Keep tests isolated: each test starts fresh.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import pg from 'pg';
describe('Isolated tests with rollback', () => {
let client: pg.PoolClient;
beforeEach(async () => {
const pool = new pg.Pool({ /* ... */ });
client = await pool.connect();
await client.query('BEGIN');
});
afterEach(async () => {
await client.query('ROLLBACK');
await client.release();
});
it('should isolate first test', async () => {
await client.query(
'INSERT INTO users (email) VALUES ($1)',
['test1@example.com']
);
const result = await client.query('SELECT COUNT(*) FROM users');
expect(parseInt(result.rows[0].count)).toBe(1);
});
it('should isolate second test', async () => {
// First test's insert is rolled back
const result = await client.query('SELECT COUNT(*) FROM users');
expect(parseInt(result.rows[0].count)).toBe(0);
await client.query(
'INSERT INTO users (email) VALUES ($1)',
['test2@example.com']
);
const newResult = await client.query('SELECT COUNT(*) FROM users');
expect(parseInt(newResult.rows[0].count)).toBe(1);
});
});
// Alternative: truncate strategy (cleaner for some DBs)
describe('Isolated tests with truncate', () => {
let pool: pg.Pool;
beforeEach(async () => {
pool = new pg.Pool({ /* ... */ });
});
afterEach(async () => {
// Clear all data
await pool.query('TRUNCATE TABLE users CASCADE');
await pool.end();
});
it('should start clean', async () => {
const result = await pool.query('SELECT COUNT(*) FROM users');
expect(parseInt(result.rows[0].count)).toBe(0);
});
});
Contract Testing with Pact
Test API contracts between services without full integration.
npm install -D @pact-foundation/pact
import { describe, it, expect } from 'vitest';
import { Pact, Matchers } from '@pact-foundation/pact';
const { like, eachLike } = Matchers;
describe('User API contract', () => {
const provider = new Pact({
consumer: 'user-service',
provider: 'api-service',
port: 8080,
});
beforeAll(() => {
return provider.setup();
});
afterEach(() => {
return provider.verify();
});
afterAll(() => {
return provider.finalize();
});
it('should return user by ID', () => {
return provider
.addInteraction({
state: 'user 1 exists',
uponReceiving: 'a request for user 1',
withRequest: {
method: 'GET',
path: '/users/1',
},
willRespondWith: {
status: 200,
body: like({
id: 1,
email: 'user@example.com',
name: 'User',
}),
},
})
.then(() => {
// Make request to provider (running on 8080)
return fetch('http://localhost:8080/users/1').then((res) =>
res.json()
);
})
.then((data) => {
expect(data.id).toBe(1);
expect(data.email).toBe('user@example.com');
});
});
it('should list users', () => {
return provider
.addInteraction({
state: 'users exist',
uponReceiving: 'a request for all users',
withRequest: {
method: 'GET',
path: '/users',
},
willRespondWith: {
status: 200,
body: eachLike(
{
id: like(1),
email: like('user@example.com'),
name: like('User'),
},
{ min: 1 }
),
},
})
.then(() => fetch('http://localhost:8080/users').then((r) => r.json()))
.then((data) => {
expect(Array.isArray(data)).toBe(true);
expect(data[0]).toHaveProperty('id');
expect(data[0]).toHaveProperty('email');
});
});
});
Snapshot Testing for API Responses
Capture API response shape once, detect changes automatically.
import { describe, it, expect } from 'vitest';
describe('API response snapshots', () => {
it('should match user response shape', async () => {
const response = await fetch('https://api.example.com/users/1');
const data = await response.json();
// First run: creates snapshot
// Subsequent runs: compares against snapshot
expect(data).toMatchSnapshot();
});
it('should match list response', async () => {
const response = await fetch('https://api.example.com/users');
const data = await response.json();
expect(data).toMatchSnapshot('user-list');
});
// To update snapshots:
// vitest run -u
});
// Snapshot file (auto-generated)
// api.test.ts.snap
/*
exports[`API response snapshots should match user response shape 1`] = `
Object {
"id": 1,
"email": "user@example.com",
"name": "User",
}
`;
*/
Coverage Thresholds as CI Gates
Enforce minimum coverage to prevent regressions.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
branches: 80, // 80% branch coverage minimum
functions: 80,
lines: 85,
statements: 85,
exclude: ['node_modules/', 'dist/'],
},
},
});
# Run with coverage
vitest run --coverage
# Fails if below threshold
# FAIL src/index.ts
# Statements: 70/100 (70%) - Required 85%
Checklist
- ✓ Use Vitest instead of Jest for new projects (faster, ESM native)
- ✓ Run real databases with TestContainers, not mocks
- ✓ Mock HTTP with MSW at network level, not fetch/axios
- ✓ Isolate tests: transaction rollback or truncate between tests
- ✓ Use Pact for contract testing between services
- ✓ Capture snapshots for API responses, detect breaking changes
- ✓ Set coverage thresholds (80%+) and enforce in CI
- ✓ Test async code: promises, await, callbacks
- ✓ Avoid over-mocking: mock external services, test logic with real data
Conclusion
Modern testing stops mocking internal logic and instead validates behavior against real databases and services. Vitest's speed enables this—run hundreds of tests in seconds. Combined with TestContainers and MSW, you gain confidence that your code works in production, not just in mock-land.