Node.js Testing: Complete Theory & Practice Guide
Build confidence with unit, integration, E2E, and robust mocking strategies.
JestSupertestPlaywright
Table of Contents
1. Theory: Testing Fundamentals
Why Testing Matters
Testing is the systematic verification that your code behaves as expected. It's not just about finding bugs—it's about building confidence to refactor, deploy, and scale your application.
/\
/ \
/ \
/ E2E \ ← Fewer tests, slower, higher confidence
/--------\
/ \
/ Integration \ ← Medium count, medium speed
/--------------\
/ \
/ Unit \ ← Most tests, fastest, isolated
/--------------------\
| Level | What It Tests | Speed | Mocking | Confidence |
|---|---|---|---|---|
| Unit | Single function/class | ⚡ Milliseconds | Heavy | Low |
| Integration | Module interactions | 📦 Seconds | Moderate | Medium |
| E2E | Complete user flows | 🐌 Seconds/minutes | Minimal | High |
// test-types.js
const testTypes = {
unit: {
purpose: 'Verify individual functions/methods work correctly',
example: 'expect(add(2, 3)).toBe(5)',
characteristics: ['Fast', 'Isolated', 'Many', 'Cheap']
},
integration: {
purpose: 'Verify database, API, and service interactions',
example: 'expect(await userService.create(data)).toHaveProperty("id")',
characteristics: ['Slower', 'Real dependencies', 'Fewer', 'Moderate cost']
},
e2e: {
purpose: 'Verify critical user journeys work end-to-end',
example: 'await page.click("#login"); await page.waitForNavigation()',
characteristics: ['Slowest', 'Real browser', 'Very few', 'Expensive']
},
snapshot: {
purpose: 'Catch unintended UI or output changes',
example: 'expect(renderComponent()).toMatchSnapshot()',
characteristics: ['Quick', 'Fragile', 'Use sparingly']
},
smoke: {
purpose: 'Quick check that app is not completely broken',
example: 'expect(await api.health()).toBe(200)',
characteristics: ['Fast', 'Basic', 'Run on every deploy']
},
regression: {
purpose: 'Verify fixed bugs stay fixed',
example: 'Write test that reproduces bug, fix code, test passes',
characteristics: ['Permanent', 'Specific', 'High value']
}
};
// test-doubles.js
const testDoubles = {
dummy: {
description: 'Object with no implementation',
useCase: 'Filling parameter lists',
example: 'function test(dummy, callback) { callback(); }'
},
stub: {
description: 'Provides predefined responses',
useCase: 'Controlled test scenarios',
example: 'database.getUser = jest.fn().mockReturnValue({ id: 1 })'
},
spy: {
description: 'Wraps real function, records calls',
useCase: 'Verify function was called with correct arguments',
example: 'expect(sendEmail).toHaveBeenCalledWith("user@example.com")'
},
mock: {
description: 'Fully controlled test double',
useCase: 'Complex interaction verification',
example: 'expect(mockAPI.getData).toHaveBeenCalledTimes(1)'
},
fake: {
description: 'Lightweight alternative to real dependency',
useCase: 'In-memory database for testing',
example: 'new InMemoryDatabase() instead of PostgreSQL'
}
};
┌─────────────────────────────────────────────────────────────┐
│ 70% Unit Tests │
│ Fast, isolated, numerous │
├─────────────────────────────────────────────────────────────┤
│ 20% Integration │
│ Module interactions, databases, APIs │
├─────────────────────────────────────────────────────────────┤
│ 10% E2E │
│ Critical user journeys │
└─────────────────────────────────────────────────────────────┘
| Framework | Best For | Key Features |
|---|---|---|
| Jest | General purpose | Zero config, snapshots, coverage |
| Mocha | Flexibility | Customizable, many assertions |
| Vitest | Vite projects | Fast, ESM native |
| Supertest | HTTP testing | Express/API testing |
| Playwright | E2E | Multi-browser, auto-wait |
| Cypress | E2E | Time travel, real reloads |
| Sinon | Mocks/spies | Standalone test doubles |
2. Basic: Unit Testing with Jest
npm install --save-dev jest @types/jest
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
// math.js - Code to test
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
function multiply(a, b) { return a * b; }
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
// math.test.js - Tests
describe('Math operations', () => {
test('adds 1 + 2 to equal 3', () => { expect(add(1, 2)).toBe(3); });
test('subtracts 5 - 3 to equal 2', () => { expect(subtract(5, 3)).toBe(2); });
test('multiplies 3 * 4 to equal 12', () => { expect(multiply(3, 4)).toBe(12); });
test('divides 10 / 2 to equal 5', () => { expect(divide(10, 2)).toBe(5); });
test('throws error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
// matchers.test.js
describe('Jest Matchers', () => {
test('equality matchers', () => {
expect(2 + 2).toBe(4);
expect({ name: 'John' }).toEqual({ name: 'John' });
expect([1, 2, 3]).toStrictEqual([1, 2, 3]);
});
test('truthiness matchers', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(0).toBeDefined();
});
test('numeric matchers', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(0.1 + 0.2).toBeCloseTo(0.3);
});
test('string matchers', () => {
expect('Hello World').toMatch(/World/);
expect('Hello World').toContain('Hello');
expect('Hello World').toHaveLength(11);
});
test('array matchers', () => {
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
expect([1, 2, 3]).toEqual(expect.arrayContaining([2, 3]));
});
test('object matchers', () => {
expect({ id: 1, name: 'John' }).toHaveProperty('name');
expect({ id: 1, name: 'John' }).toHaveProperty('name', 'John');
expect({ id: 1, name: 'John' }).toMatchObject({ name: 'John' });
});
test('exception matchers', () => {
const throwError = () => { throw new Error('Failed'); };
expect(throwError).toThrow();
expect(throwError).toThrow('Failed');
expect(throwError).toThrow(/Fail/);
});
});
// async.js
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
function fetchWithCallback(id, callback) {
setTimeout(() => callback(null, { id, name: 'John' }), 100);
}
// async.test.js
describe('Async testing', () => {
test('resolves to user data', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('name');
});
test('using .resolves matcher', () => {
return expect(fetchUser(1)).resolves.toHaveProperty('name');
});
test('rejects on error', async () => {
await expect(fetchUser(999)).rejects.toThrow();
});
test('callback receives data', (done) => {
fetchWithCallback(1, (error, data) => {
expect(error).toBeNull();
expect(data.name).toBe('John');
done();
});
});
});
// user-service.js
class UserService {
constructor(database) { this.db = database; }
async createUser(data) {
if (!data.email) throw new Error('Email required');
const existing = await this.db.findByEmail(data.email);
if (existing) throw new Error('User exists');
const user = { id: Date.now(), ...data, createdAt: new Date() };
await this.db.save(user);
return user;
}
async getUser(id) {
const user = await this.db.findById(id);
if (!user) throw new Error('User not found');
return user;
}
}
// user-service.test.js
describe('UserService', () => {
let service;
let mockDb;
beforeEach(() => {
mockDb = { findByEmail: jest.fn(), findById: jest.fn(), save: jest.fn() };
service = new UserService(mockDb);
});
afterEach(() => { jest.clearAllMocks(); });
test('creates user successfully', async () => {
const userData = { email: 'test@example.com', name: 'Test' };
mockDb.findByEmail.mockResolvedValue(null);
mockDb.save.mockResolvedValue(true);
const result = await service.createUser(userData);
expect(result).toHaveProperty('id');
expect(result.email).toBe('test@example.com');
expect(mockDb.save).toHaveBeenCalledTimes(1);
});
test('throws error for duplicate email', async () => {
mockDb.findByEmail.mockResolvedValue({ id: 1 });
await expect(service.createUser({ email: 'existing@example.com' }))
.rejects.toThrow('User exists');
});
test('throws error for missing email', async () => {
await expect(service.createUser({ name: 'Test' }))
.rejects.toThrow('Email required');
});
});
// lifecycle.test.js
describe('Test lifecycle hooks', () => {
beforeAll(() => { console.log('Setup database connection'); });
beforeEach(() => { console.log('Reset test data'); });
afterEach(() => { console.log('Clean up after test'); });
afterAll(() => { console.log('Close database connection'); });
test('test 1', () => { expect(true).toBe(true); });
test('test 2', () => { expect(1 + 1).toBe(2); });
});
// snapshot.test.js
const user = { id: 1, name: 'John', email: 'john@example.com' };
test('user object matches snapshot', () => { expect(user).toMatchSnapshot(); });
test('user object matches inline snapshot', () => {
expect(user).toMatchInlineSnapshot(`
{
"email": "john@example.com",
"id": 1,
"name": "John",
}
`);
});
3. Advanced: Integration Testing
npm install --save-dev supertest
// app.js + app.test.js (Supertest)
const request = require('supertest');
const app = require('./app');
describe('User API', () => {
let createdUserId;
test('POST /api/users - creates new user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'John Doe', email: 'john@example.com' })
.expect(201);
expect(response.body).toHaveProperty('id');
createdUserId = response.body.id;
});
test('GET /api/users/:id - returns user', async () => {
const response = await request(app).get(`/api/users/${createdUserId}`).expect(200);
expect(response.body.id).toBe(createdUserId);
});
test('DELETE /api/users/:id - removes user', async () => {
await request(app).delete(`/api/users/${createdUserId}`).expect(204);
await request(app).get(`/api/users/${createdUserId}`).expect(404);
});
});
// db-service.js + db-service.test.js
const { Pool } = require('pg');
class UserRepository {
constructor(pool) { this.pool = pool; }
async create(user) {
const result = await this.pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[user.name, user.email]
);
return result.rows[0];
}
async findById(id) {
const result = await this.pool.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0];
}
async findAll() {
const result = await this.pool.query('SELECT * FROM users ORDER BY id');
return result.rows;
}
async clear() {
await this.pool.query('TRUNCATE users RESTART IDENTITY');
}
}
// testcontainers.test.js
const { GenericContainer } = require('testcontainers');
const { Pool } = require('pg');
describe('PostgreSQL with Testcontainers', () => {
let container;
let pool;
beforeAll(async () => {
container = await new GenericContainer('postgres:14')
.withEnvironment({
POSTGRES_USER: 'test',
POSTGRES_PASSWORD: 'test',
POSTGRES_DB: 'testdb'
})
.withExposedPorts(5432)
.start();
const port = container.getMappedPort(5432);
pool = new Pool({ host: 'localhost', port, database: 'testdb', user: 'test', password: 'test' });
await pool.query(`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`);
});
afterAll(async () => {
await pool.end();
await container.stop();
});
test('database works in container', async () => {
await pool.query('INSERT INTO users (name) VALUES ($1)', ['Test User']);
const result = await pool.query('SELECT * FROM users');
expect(result.rows).toHaveLength(1);
});
});
4. Advanced: E2E Testing
npm install --save-dev @playwright/test
npx playwright install
// playwright.config.js
module.exports = {
testDir: './e2e',
timeout: 30000,
use: {
baseURL: 'http://localhost:3000',
headless: true,
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } }
]
};
// e2e/login.spec.js
const { test, expect } = require('@playwright/test');
test.describe('Login Flow', () => {
test.beforeEach(async ({ page }) => { await page.goto('/login'); });
test('successful login redirects to dashboard', async ({ page }) => {
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'correct-password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.welcome-message')).toContainText('Welcome back');
});
});
// e2e/todo.spec.js
test.describe('Todo App', () => {
test.beforeEach(async ({ page }) => { await page.goto('/todos'); });
test('adds new todo item', async ({ page }) => {
await page.fill('#new-todo', 'Buy groceries');
await page.press('#new-todo', 'Enter');
const todoItem = page.locator('.todo-item:last-child');
await expect(todoItem).toContainText('Buy groceries');
});
test('filters todos', async ({ page }) => {
await page.fill('#new-todo', 'Active task');
await page.press('#new-todo', 'Enter');
await page.fill('#new-todo', 'Completed task');
await page.press('#new-todo', 'Enter');
await page.click('.todo-item:last-child .checkbox');
await page.click('button:has-text("Active")');
await expect(page.locator('.todo-item')).toHaveCount(1);
});
});
// e2e/api.spec.js
const request = require('supertest');
const app = require('../app');
describe('E2E API Tests', () => {
let authToken;
test('user registration flow', async () => {
const registerRes = await request(app)
.post('/api/auth/register')
.send({ email: 'e2e@example.com', password: 'Password123!', name: 'E2E Test' });
expect(registerRes.status).toBe(201);
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: 'e2e@example.com', password: 'Password123!' });
expect(loginRes.status).toBe(200);
authToken = loginRes.body.token;
});
test('protected endpoints require auth', async () => {
await request(app).get('/api/users/me').expect(401);
});
test('CRUD operations flow', async () => {
const createRes = await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'E2E Test Post', content: 'This is a test post' });
expect(createRes.status).toBe(201);
});
});
5. Advanced: Mocking & Stubs
// api-client.js / api-client.test.js
class APIClient {
async fetchUsers() {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
return response.json();
}
async createUser(data) {
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
return response.json();
}
}
global.fetch = jest.fn();
describe('APIClient', () => {
let client;
beforeEach(() => { client = new APIClient(); fetch.mockClear(); });
test('fetchUsers returns data', async () => {
const mockUsers = [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }];
fetch.mockResolvedValueOnce({ ok: true, json: async () => mockUsers });
const users = await client.fetchUsers();
expect(users).toEqual(mockUsers);
});
});
// module mocking
jest.mock('./email-service', () => ({
sendWelcomeEmail: jest.fn().mockResolvedValue({ success: true })
}));
describe('UserController', () => {
test('sends welcome email on registration', async () => {
const controller = new UserController();
const mockReq = { body: { email: 'test@example.com', name: 'Test' } };
const mockRes = { json: jest.fn() };
await controller.register(mockReq, mockRes);
const { sendWelcomeEmail } = require('./email-service');
expect(sendWelcomeEmail).toHaveBeenCalledWith('test@example.com');
});
});
// __mocks__/database.js
const mockDb = {
users: [],
query: jest.fn(async (sql, params) => {
if (sql.includes('INSERT INTO users')) {
const user = { id: mockDb.users.length + 1, ...params[0] };
mockDb.users.push(user);
return { rows: [user] };
}
if (sql.includes('SELECT * FROM users')) return { rows: mockDb.users };
return { rows: [] };
}),
clear: () => {
mockDb.users = [];
mockDb.query.mockClear();
}
};
// sinon-example.test.js
const sinon = require('sinon');
describe('Sinon Mocks and Spies', () => {
let sandbox;
beforeEach(() => { sandbox = sinon.createSandbox(); });
afterEach(() => { sandbox.restore(); });
test('clock controls time', async () => {
const clock = sandbox.useFakeTimers();
const callback = sandbox.spy();
setTimeout(callback, 1000);
clock.tick(500);
expect(callback.called).toBe(false);
clock.tick(500);
expect(callback.calledOnce).toBe(true);
clock.restore();
});
});
6. Best Practices
// aaa-pattern.test.js
describe('Calculate Total', () => {
test('applies discount correctly', () => {
// Arrange
const items = [{ price: 100, quantity: 2 }, { price: 50, quantity: 1 }];
const discount = 0.1;
// Act
const total = calculateTotal(items, discount);
// Assert
expect(total).toBe(225);
});
});
// naming-conventions.test.js
describe('UserService', () => {
test('createUser_withValidData_returnsUser', () => {});
test('createUser_withDuplicateEmail_throwsError', () => {});
test('createUser_withMissingEmail_throwsValidationError', () => {});
test('should send welcome email when user registers', () => {});
test('should return 404 when user not found', () => {});
test('should reject request when token is invalid', () => {});
});
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/critical/': { branches: 100, functions: 100, lines: 100, statements: 100 }
},
coveragePathIgnorePatterns: ['/node_modules/', '/__tests__/', '/migrations/', '/*.config.js'],
coverageReporters: ['text', 'html', 'lcov', 'json'],
testEnvironment: 'node',
setupFilesAfterEnv: ['./jest.setup.js']
};
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
"test:ci": "jest --ci --coverage --maxWorkers=2",
"test:coverage": "jest --coverage && open coverage/lcov-report/index.html",
"test:unit": "jest --testPathPattern=unit",
"test:integration": "jest --testPathPattern=integration --runInBand",
"test:e2e": "playwright test",
"test:performance": "jest --testPathPattern=performance",
"test:smoke": "jest --testPathPattern=smoke --bail",
"test:update": "jest --updateSnapshot"
}
}
// test-utils.js
class TestFactory {
static createUser(overrides = {}) {
return {
id: Date.now(),
name: 'Test User',
email: `test_${Date.now()}@example.com`,
createdAt: new Date(),
...overrides
};
}
}
class TestDatabase {
constructor(connection) { this.connection = connection; this.transaction = null; }
async beginTransaction() { this.transaction = await this.connection.beginTransaction(); return this.transaction; }
async rollback() { if (this.transaction) { await this.transaction.rollback(); this.transaction = null; } }
}
class TestAPI {
constructor(app) { this.app = app; this.authToken = null; }
async login(email, password) {
const response = await request(this.app).post('/api/auth/login').send({ email, password });
this.authToken = response.body.token;
return this.authToken;
}
}
// checklist.js
const testingChecklist = {
unit: [
'✅ Test happy path',
'✅ Test edge cases (null, undefined, empty)',
'✅ Test error conditions',
'✅ Test boundary values',
'✅ Each test tests ONE thing',
'✅ Tests are independent',
'✅ No network/database calls',
'✅ Mock external dependencies'
],
integration: [
'✅ Test database operations',
'✅ Test API endpoints',
'✅ Test error responses',
'✅ Test authentication',
'✅ Test request validation',
'✅ Use test database',
'✅ Clean up after tests',
'✅ Test real implementations not mocks'
],
e2e: [
'✅ Test critical user journeys',
'✅ Test across browsers',
'✅ Test responsive design',
'✅ Test error UI states',
'✅ Test loading states',
'✅ Test form submissions',
'✅ Test navigation flows',
'✅ Run in CI pipeline'
],
performance: [
'✅ Load testing for critical endpoints',
'✅ Stress testing for peak loads',
'✅ Response time benchmarks',
'✅ Memory leak detection',
'✅ Database query performance',
'✅ Caching effectiveness'
]
};
Summary
| Test Type | Tool | Speed | Confidence | When to Use |
|---|---|---|---|---|
| Unit | Jest | ⚡⚡⚡ | Low | Every function/class |
| Integration | Supertest | ⚡⚡ | Medium | API routes, DB queries |
| Component | Testing Library | ⚡⚡ | Medium | React/Vue components |
| E2E | Playwright | ⚡ | High | Critical user journeys |
| Smoke | Jest | ⚡⚡⚡ | Medium | Pre-deployment checks |
| Regression | Jest | ⚡⚡ | High | After bug fixes |
# Run tests
npm test
# Watch mode (auto-run on changes)
npm run test:watch
# Coverage report
npm run test:coverage
# Run specific test file
npm test -- user-service.test.js
# Run tests matching pattern
npm test -- -t "creates user"
# Debug mode
npm run test:debug
# Update snapshots
npm run test:update
# CI mode (non-watch)
npm run test:ci
10 Interview Questions + 10 MCQs
1What is the testing pyramid?easy
Answer: A strategy with many unit tests, fewer integration tests, and very few E2E tests.
2Why keep unit tests isolated?easy
Answer: Isolation makes tests fast, deterministic, and easier to debug.
3When should integration tests be used?medium
Answer: To verify interactions between modules, DB, APIs, and middleware.
4What does Supertest provide?easy
Answer: HTTP assertions for Express/Node APIs without spinning a full external client.
5What is a test double?medium
Answer: A substitute for a dependency (dummy, stub, spy, mock, fake).
6Why avoid testing private methods directly?medium
Answer: It couples tests to implementation details and breaks refactoring safety.
7What is snapshot testing best for?easy
Answer: Detecting unintended output/UI changes in stable components.
8Why run tests in CI?easy
Answer: To catch regressions automatically before deployment.
9What makes E2E tests expensive?medium
Answer: Full-stack/browser setup, slower execution, and higher maintenance.
10How should coverage be interpreted?hard
Answer: As a signal, not a goal; high coverage does not guarantee meaningful assertions.
10 Testing MCQs
1
Most tests should be:
Explanation: Unit tests are fast and numerous.
2
Jest is mainly used for:
Explanation: Jest is a test runner + assertion/mocking framework.
3
Supertest is best for:
Explanation: Supertest targets HTTP route testing.
4
A stub usually:
Explanation: Stubs provide canned responses.
5
Playwright is primarily for:
Explanation: Playwright automates real browsers.
6
AAA pattern means:
Explanation: AAA is a clear test structure pattern.
7
Good test naming should:
Explanation: Readable names document behavior and intent.
8
Integration tests should use:
Explanation: Integration tests validate modules working together.
9
A spy is used to:
Explanation: Spies observe call counts/arguments.
10
Coverage is:
Explanation: Assertions and scenario quality matter more than percentage alone.