Testing in Node.js
Automated testing is essential for maintaining code quality and preventing regressions. Modern Node.js testing has converged around Jest and Vitest, both offering similar APIs but with Vitest providing faster execution through native ESM and Vite integration.
🧪 Testing Pyramid
E2E Tests
Few, slow, high confidence
Integration Tests
Some, medium speed
Unit Tests
Many, fast, low overhead
Unit Testing Basics
// math.js
export function add(a, b) { return a + b; }
export function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
// math.test.js
import { describe, it, expect } from 'vitest'; // or jest
import { add, divide } from './math.js';
describe('Math utilities', () => {
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
it('handles zero', () => {
expect(add(0, 5)).toBe(5);
});
});
describe('divide', () => {
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('returns decimals', () => {
expect(divide(1, 3)).toBeCloseTo(0.333, 2);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
});
Mocking Modules and Functions
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUser, createUser } from './user-service.js';
// Mock entire module
vi.mock('./database.js', () => ({
query: vi.fn(),
connect: vi.fn(),
}));
import { query } from './database.js';
describe('UserService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches a user by ID', async () => {
// Arrange: set up mock return value
query.mockResolvedValue({
rows: [{ id: 1, name: 'Alice', email: 'alice@test.com' }]
});
// Act
const user = await getUser(1);
// Assert
expect(query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = $1',
[1]
);
expect(user).toEqual({
id: 1,
name: 'Alice',
email: 'alice@test.com'
});
});
it('throws NotFoundError for missing user', async () => {
query.mockResolvedValue({ rows: [] });
await expect(getUser(999)).rejects.toThrow('User not found');
});
it('creates a user with hashed password', async () => {
query.mockResolvedValue({
rows: [{ id: 1, name: 'Bob', email: 'bob@test.com' }]
});
const user = await createUser({
name: 'Bob',
email: 'bob@test.com',
password: 'secret123'
});
// Verify password was hashed (not stored in plain text)
const callArgs = query.mock.calls[0][1];
expect(callArgs[2]).not.toBe('secret123');
expect(callArgs[2]).toMatch(/^\$2[aby]?\$/); // bcrypt pattern
});
});
Testing Async Code
import { describe, it, expect, vi } from 'vitest';
// Test promises
it('resolves with user data', async () => {
const data = await fetchUser(1);
expect(data).toHaveProperty('name');
});
// Test rejections
it('rejects with error for invalid ID', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});
// Test timers
describe('delayed operations', () => {
it('retries failed operations', async () => {
vi.useFakeTimers();
const mockFetch = vi.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ data: 'success' });
const promise = fetchWithRetry(mockFetch, { maxRetries: 3, delay: 1000 });
// Fast-forward through retry delays
await vi.advanceTimersByTimeAsync(1000); // First retry
await vi.advanceTimersByTimeAsync(1000); // Second retry
const result = await promise;
expect(result).toEqual({ data: 'success' });
expect(mockFetch).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
});
// Test event emitters
it('emits events in order', () => {
const emitter = new TaskRunner();
const events = [];
emitter.on('start', () => events.push('start'));
emitter.on('complete', () => events.push('complete'));
emitter.run();
expect(events).toEqual(['start', 'complete']);
});
Integration Testing with Supertest
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from './app.js';
import { setupTestDB, teardownTestDB } from './test-helpers.js';
describe('User API', () => {
let app;
beforeAll(async () => {
await setupTestDB();
app = createApp();
});
afterAll(async () => {
await teardownTestDB();
});
it('POST /api/users creates a user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@test.com' })
.expect(201);
expect(res.body).toMatchObject({
name: 'Alice',
email: 'alice@test.com'
});
expect(res.body).toHaveProperty('id');
});
it('GET /api/users/:id returns user', async () => {
const res = await request(app)
.get('/api/users/1')
.set('Authorization', 'Bearer test-token')
.expect(200);
expect(res.body.name).toBe('Alice');
});
it('returns 400 for invalid input', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: '' })
.expect(400);
expect(res.body).toHaveProperty('error');
});
it('returns 404 for non-existent user', async () => {
await request(app)
.get('/api/users/99999')
.expect(404);
});
});
Code Coverage and Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'test/',
'**/*.test.ts',
'**/*.d.ts',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
}
},
setupFiles: ['./test/setup.ts'],
}
});
// test/setup.ts
import { beforeAll, afterAll } from 'vitest';
beforeAll(async () => {
// Global test setup: seed database, start services, etc.
});
afterAll(async () => {
// Global cleanup
});
// Run tests:
// npx vitest # Watch mode
// npx vitest run # Single run
// npx vitest --coverage # With coverage report
// npx vitest --reporter=verbose # Detailed output
💡 Key Takeaways
- • Follow the testing pyramid: many unit tests, fewer integration tests
- • Use
vi.mock()to isolate units from their dependencies - • Test both success paths and error paths
- • Use
supertestfor HTTP endpoint integration tests - • Set coverage thresholds to maintain quality over time