Testing Best Practices
This skill provides comprehensive expert knowledge of testing Node.js/Express applications with emphasis on Jest and Supertest, test organization, mocking strategies, and achieving comprehensive test coverage.
Testing Framework Setup
Jest Installation and Configuration
Install dependencies:
npm install --save-dev jest supertest @types/jest
package.json configuration:
{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:verbose": "jest --verbose" }, "jest": { "testEnvironment": "node", "coveragePathIgnorePatterns": [ "/node_modules/" ], "testMatch": [ "/tests//.js", "**/?(.)+(spec|test).js" ] } }
jest.config.js (advanced):
module.exports = { // Use Node.js test environment testEnvironment: 'node',
// Test file patterns testMatch: [ '/tests//.js', '**/.test.js', '**/*.spec.js' ],
// Coverage settings collectCoverageFrom: [ 'src//*.js', 'routes//*.js', '!src/index.js', // Exclude entry point '!/node_modules/' ],
// Coverage thresholds coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } },
// Setup files setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
// Clear mocks between tests clearMocks: true,
// Verbose output verbose: true,
// Timeout for tests testTimeout: 10000 };
Test Directory Structure
Option 1: Separate test directory:
project/ ├── src/ │ ├── server.js │ ├── routes/ │ │ └── api.js │ └── utils/ │ └── validators.js ├── test/ │ ├── setup.js │ ├── server.test.js │ ├── routes/ │ │ └── api.test.js │ └── utils/ │ └── validators.test.js └── package.json
Option 2: Co-located tests:
project/ ├── src/ │ ├── server.js │ ├── server.test.js │ ├── routes/ │ │ ├── api.js │ │ └── api.test.js │ └── utils/ │ ├── validators.js │ └── validators.test.js └── package.json
Option 3: tests directories:
project/ ├── src/ │ ├── tests/ │ │ └── server.test.js │ ├── server.js │ ├── routes/ │ │ ├── tests/ │ │ │ └── api.test.js │ │ └── api.js └── package.json
Testing Express Applications with Supertest
Basic API Testing
const request = require('supertest'); const app = require('../server');
describe('GET /', () => { it('should return 200 status', async () => { const response = await request(app).get('/'); expect(response.status).toBe(200); });
it('should return JSON content type', async () => { const response = await request(app).get('/api/users'); expect(response.headers['content-type']).toMatch(/json/); });
it('should return users array', async () => { const response = await request(app).get('/api/users'); expect(response.body).toHaveProperty('users'); expect(Array.isArray(response.body.users)).toBe(true); }); });
describe('POST /api/users', () => { it('should create a user with valid data', async () => { const userData = { name: 'John Doe', email: 'john@example.com', password: 'SecurePass123!' };
const response = await request(app)
.post('/api/users')
.send(userData)
.set('Content-Type', 'application/json')
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.email).toBe(userData.email);
expect(response.body).not.toHaveProperty('password'); // Don't return password
});
it('should reject invalid email', async () => { const response = await request(app) .post('/api/users') .send({ name: 'John', email: 'invalid-email', password: 'SecurePass123!' }) .expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/email/i);
});
it('should reject weak password', async () => { const response = await request(app) .post('/api/users') .send({ name: 'John', email: 'john@example.com', password: '123' // Too short }) .expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/password/i);
}); });
describe('Authentication', () => { let authToken;
beforeAll(async () => { // Create a test user and get token const response = await request(app) .post('/api/login') .send({ email: 'test@example.com', password: 'TestPass123!' });
authToken = response.body.token;
});
it('should access protected route with valid token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', Bearer ${authToken})
.expect(200);
expect(response.body).toHaveProperty('user');
});
it('should reject access without token', async () => { await request(app) .get('/api/profile') .expect(401); });
it('should reject invalid token', async () => { await request(app) .get('/api/profile') .set('Authorization', 'Bearer invalid-token') .expect(401); }); });
Testing Proxy Endpoints
const request = require('supertest'); const axios = require('axios'); const app = require('../server');
// Mock axios jest.mock('axios');
describe('POST /api/proxy', () => { afterEach(() => { jest.clearAllMocks(); });
it('should proxy request successfully', async () => { const mockData = { results: [ { id: 1, name: 'Result 1' }, { id: 2, name: 'Result 2' } ] };
axios.post.mockResolvedValue({
data: mockData,
status: 200
});
const response = await request(app)
.post('/api/proxy')
.send({ query: 'test' })
.expect(200);
expect(response.body).toEqual(mockData);
expect(axios.post).toHaveBeenCalledWith(
expect.any(String),
{ query: 'test' },
expect.any(Object)
);
});
it('should handle proxy errors', async () => { axios.post.mockRejectedValue({ response: { status: 500, data: { error: 'Internal Server Error' } } });
const response = await request(app)
.post('/api/proxy')
.send({ query: 'test' })
.expect(500);
expect(response.body).toHaveProperty('error');
});
it('should handle network errors', async () => { axios.post.mockRejectedValue(new Error('Network error'));
const response = await request(app)
.post('/api/proxy')
.send({ query: 'test' })
.expect(500);
expect(response.body).toHaveProperty('error');
});
it('should validate request before proxying', async () => { const response = await request(app) .post('/api/proxy') .send({ invalid: 'data' }) .expect(400);
expect(response.body).toHaveProperty('error');
expect(axios.post).not.toHaveBeenCalled();
}); });
Mocking Strategies
Mocking External APIs
Mock entire module:
jest.mock('axios');
const axios = require('axios');
describe('External API calls', () => { it('should fetch data from external API', async () => { const mockData = { data: 'test' }; axios.get.mockResolvedValue({ data: mockData });
const result = await fetchExternalData();
expect(result).toEqual(mockData);
expect(axios.get).toHaveBeenCalledWith('https://api.example.com/data');
}); });
Mock specific functions:
const userService = require('../services/user');
jest.spyOn(userService, 'findById').mockResolvedValue({ id: 1, name: 'Test User' });
describe('User routes', () => { it('should get user by id', async () => { const response = await request(app) .get('/api/users/1') .expect(200);
expect(response.body.name).toBe('Test User');
expect(userService.findById).toHaveBeenCalledWith('1');
}); });
Manual mocks:
// mocks/axios.js module.exports = { get: jest.fn(() => Promise.resolve({ data: {} })), post: jest.fn(() => Promise.resolve({ data: {} })), put: jest.fn(() => Promise.resolve({ data: {} })), delete: jest.fn(() => Promise.resolve({ data: {} })) };
Mocking Database
// Mock database module jest.mock('../db');
const db = require('../db');
describe('Database operations', () => { beforeEach(() => { db.query.mockClear(); });
it('should query users', async () => { const mockUsers = [ { id: 1, name: 'User 1' }, { id: 2, name: 'User 2' } ];
db.query.mockResolvedValue({ rows: mockUsers });
const users = await User.findAll();
expect(users).toEqual(mockUsers);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
});
it('should handle database errors', async () => { db.query.mockRejectedValue(new Error('Connection failed'));
await expect(User.findAll()).rejects.toThrow('Connection failed');
}); });
Mocking Environment Variables
describe('Environment configuration', () => { const originalEnv = process.env;
beforeEach(() => { jest.resetModules(); process.env = { ...originalEnv }; });
afterAll(() => { process.env = originalEnv; });
it('should use default port when PORT not set', () => { delete process.env.PORT; const config = require('../config'); expect(config.port).toBe(3000); });
it('should use PORT from environment', () => { process.env.PORT = '8080'; const config = require('../config'); expect(config.port).toBe(8080); }); });
Unit vs Integration vs E2E Testing
Unit Tests
What: Test individual functions/modules in isolation
Example:
// validators.js function isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+.[^\s@]+$/; return emailRegex.test(email); }
function isStrongPassword(password) { return password.length >= 12 && /[A-Z]/.test(password) && /[a-z]/.test(password) && /[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password); }
module.exports = { isValidEmail, isStrongPassword };
// validators.test.js const { isValidEmail, isStrongPassword } = require('./validators');
describe('Email validation', () => { it('should accept valid email', () => { expect(isValidEmail('test@example.com')).toBe(true); });
it('should reject email without @', () => { expect(isValidEmail('testexample.com')).toBe(false); });
it('should reject email without domain', () => { expect(isValidEmail('test@')).toBe(false); });
it('should reject email with spaces', () => { expect(isValidEmail('test @example.com')).toBe(false); }); });
describe('Password validation', () => { it('should accept strong password', () => { expect(isStrongPassword('MyP@ssw0rd123!')).toBe(true); });
it('should reject short password', () => { expect(isStrongPassword('Short1!')).toBe(false); });
it('should reject password without uppercase', () => { expect(isStrongPassword('myp@ssw0rd123!')).toBe(false); });
it('should reject password without special char', () => { expect(isStrongPassword('MyPassword123')).toBe(false); }); });
Integration Tests
What: Test multiple components working together
Example:
const request = require('supertest'); const app = require('../server'); const db = require('../db');
describe('User registration flow', () => { beforeEach(async () => { // Clean database before each test await db.query('DELETE FROM users'); });
it('should register user and allow login', async () => { // Register user const registerResponse = await request(app) .post('/api/register') .send({ email: 'test@example.com', password: 'SecurePass123!', name: 'Test User' }) .expect(201);
expect(registerResponse.body).toHaveProperty('id');
// Login with registered credentials
const loginResponse = await request(app)
.post('/api/login')
.send({
email: 'test@example.com',
password: 'SecurePass123!'
})
.expect(200);
expect(loginResponse.body).toHaveProperty('token');
// Access protected route with token
const profileResponse = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${loginResponse.body.token}`)
.expect(200);
expect(profileResponse.body.email).toBe('test@example.com');
}); });
End-to-End (E2E) Tests
What: Test complete user workflows from UI to database
Setup with Puppeteer:
npm install --save-dev puppeteer
Example:
const puppeteer = require('puppeteer');
describe('E2E: User registration', () => { let browser; let page;
beforeAll(async () => { browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }); page = await browser.newPage(); });
afterAll(async () => { await browser.close(); });
it('should complete registration flow', async () => { // Navigate to registration page await page.goto('http://localhost:3000/register');
// Fill out form
await page.type('#email', 'test@example.com');
await page.type('#password', 'SecurePass123!');
await page.type('#confirmPassword', 'SecurePass123!');
// Submit form
await page.click('button[type="submit"]');
// Wait for redirect to dashboard
await page.waitForNavigation();
// Verify we're on dashboard
const url = page.url();
expect(url).toContain('/dashboard');
// Verify welcome message
const welcomeMessage = await page.$eval(
'.welcome',
el => el.textContent
);
expect(welcomeMessage).toContain('test@example.com');
}); });
Test Organization
Describe Blocks
describe('User API', () => { describe('GET /api/users', () => { it('should return all users', async () => { // Test implementation });
it('should support pagination', async () => {
// Test implementation
});
it('should support filtering', async () => {
// Test implementation
});
});
describe('POST /api/users', () => { it('should create user with valid data', async () => { // Test implementation });
it('should reject duplicate email', async () => {
// Test implementation
});
});
describe('PUT /api/users/:id', () => { it('should update user', async () => { // Test implementation });
it('should reject unauthorized update', async () => {
// Test implementation
});
}); });
Setup and Teardown
describe('Database tests', () => { // Runs once before all tests in this describe block beforeAll(async () => { await db.connect(); });
// Runs once after all tests in this describe block afterAll(async () => { await db.disconnect(); });
// Runs before each test in this describe block beforeEach(async () => { await db.query('DELETE FROM users'); await db.query('INSERT INTO users (email) VALUES ($1)', ['test@example.com']); });
// Runs after each test in this describe block afterEach(async () => { jest.clearAllMocks(); });
it('should find user', async () => { const user = await User.findByEmail('test@example.com'); expect(user).toBeTruthy(); });
it('should delete user', async () => { await User.deleteByEmail('test@example.com'); const user = await User.findByEmail('test@example.com'); expect(user).toBeNull(); }); });
Test Fixtures
// test/fixtures/users.js module.exports = { validUser: { email: 'test@example.com', password: 'SecurePass123!', name: 'Test User' },
adminUser: { email: 'admin@example.com', password: 'AdminPass123!', name: 'Admin User', role: 'admin' },
invalidUsers: { noEmail: { password: 'SecurePass123!', name: 'Test User' }, weakPassword: { email: 'test@example.com', password: '123', name: 'Test User' } } };
// Usage in tests const fixtures = require('./fixtures/users');
describe('User creation', () => { it('should create valid user', async () => { const response = await request(app) .post('/api/users') .send(fixtures.validUser) .expect(201); });
it('should reject user without email', async () => { const response = await request(app) .post('/api/users') .send(fixtures.invalidUsers.noEmail) .expect(400); }); });
Async Testing
Testing Promises
describe('Async operations', () => { it('should resolve with data', async () => { const data = await fetchData(); expect(data).toBeDefined(); });
it('should reject with error', async () => { await expect(fetchInvalidData()).rejects.toThrow('Not found'); });
// Alternative: using done callback it('should fetch data (callback style)', (done) => { fetchData() .then(data => { expect(data).toBeDefined(); done(); }) .catch(done); }); });
Testing Callbacks
describe('Callback functions', () => { it('should call callback with data', (done) => { fetchDataWithCallback((err, data) => { expect(err).toBeNull(); expect(data).toBeDefined(); done(); }); });
it('should call callback with error', (done) => { fetchInvalidDataWithCallback((err, data) => { expect(err).toBeTruthy(); expect(data).toBeUndefined(); done(); }); }); });
Code Coverage
Generating Coverage Reports
Run tests with coverage
npm run test:coverage
Coverage report output
----------|---------|----------|---------|---------|
| File | % Stmts | % Branch | % Funcs | % Lines |
|---|---|---|---|---|
| All files | 85.5 | 78.3 | 91.2 | 85.1 |
| server.js | 92.3 | 85.7 | 100 | 91.8 |
| routes/ | 78.9 | 71.4 | 83.3 | 79.2 |
| ---------- | --------- | ---------- | --------- | --------- |
Coverage Configuration
// jest.config.js module.exports = { collectCoverageFrom: [ 'src//*.js', '!src/index.js', // Exclude entry point '!src//*.test.js', // Exclude test files '!src//tests/' // Exclude test directories ],
coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 }, // Per-file thresholds './src/critical-module.js': { branches: 100, functions: 100, lines: 100, statements: 100 } },
coverageReporters: [ 'text', // Terminal output 'html', // HTML report in coverage/ 'lcov', // For CI tools 'json' // Machine-readable ] };
Viewing HTML Coverage Report
npm run test:coverage open coverage/index.html # macOS xdg-open coverage/index.html # Linux start coverage/index.html # Windows
Testing Best Practices
- Naming Conventions
// GOOD - Descriptive test names describe('User registration', () => { it('should create user with valid email and password', () => {}); it('should reject registration with duplicate email', () => {}); it('should hash password before storing', () => {}); });
// BAD - Vague test names describe('User', () => { it('works', () => {}); it('test 1', () => {}); it('should not fail', () => {}); });
- AAA Pattern (Arrange, Act, Assert)
it('should calculate total price with tax', () => { // Arrange: Set up test data const items = [ { price: 10, quantity: 2 }, { price: 5, quantity: 3 } ]; const taxRate = 0.1;
// Act: Perform the action const total = calculateTotal(items, taxRate);
// Assert: Verify the result expect(total).toBe(38.5); // (102 + 53) * 1.1 });
- Test One Thing
// GOOD - Each test checks one behavior it('should validate email format', () => { expect(isValidEmail('test@example.com')).toBe(true); });
it('should reject email without domain', () => { expect(isValidEmail('test@')).toBe(false); });
// BAD - Testing multiple things it('should validate inputs', () => { expect(isValidEmail('test@example.com')).toBe(true); expect(isValidPassword('pass123')).toBe(false); expect(isValidPhone('1234567890')).toBe(true); });
- Avoid Test Interdependence
// BAD - Tests depend on each other let userId;
it('should create user', async () => { const response = await createUser(); userId = response.id; // Other tests depend on this });
it('should update user', async () => { await updateUser(userId); // Fails if previous test fails });
// GOOD - Each test is independent describe('User operations', () => { let userId;
beforeEach(async () => { const user = await createUser(); userId = user.id; });
it('should update user', async () => { await updateUser(userId); });
it('should delete user', async () => { await deleteUser(userId); }); });
- Use Meaningful Assertions
// GOOD - Specific assertions expect(response.status).toBe(200); expect(response.body).toHaveProperty('users'); expect(response.body.users).toHaveLength(5); expect(response.body.users[0]).toMatchObject({ id: expect.any(Number), email: expect.stringMatching(/^[\w-.]+@([\w-]+.)+[\w-]{2,4}$/) });
// BAD - Vague assertions expect(response).toBeTruthy(); expect(response.body).toBeDefined();
- Test Edge Cases
describe('Division function', () => { it('should divide positive numbers', () => { expect(divide(10, 2)).toBe(5); });
it('should handle negative numbers', () => { expect(divide(-10, 2)).toBe(-5); });
it('should handle zero numerator', () => { expect(divide(0, 5)).toBe(0); });
it('should throw error for division by zero', () => { expect(() => divide(10, 0)).toThrow('Division by zero'); });
it('should handle decimal results', () => { expect(divide(5, 2)).toBe(2.5); }); });
CI/CD Integration
GitHub Actions
.github/workflows/test.yml
name: Tests
on: push: branches: [ main, develop ] pull_request: branches: [ main ]
jobs: test: runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Generate coverage report
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.json
fail_ci_if_error: true
npm Scripts for CI
{ "scripts": { "test": "jest", "test:ci": "jest --ci --coverage --maxWorkers=2", "test:coverage": "jest --coverage", "test:watch": "jest --watch" } }
Common Jest Matchers
Equality
expect(value).toBe(4); // Strict equality (===) expect(value).toEqual({ a: 1 }); // Deep equality expect(value).not.toBe(5); // Negation
Truthiness
expect(value).toBeTruthy(); expect(value).toBeFalsy(); expect(value).toBeNull(); expect(value).toBeUndefined(); expect(value).toBeDefined();
Numbers
expect(value).toBeGreaterThan(3); expect(value).toBeGreaterThanOrEqual(3.5); expect(value).toBeLessThan(5); expect(value).toBeLessThanOrEqual(4.5); expect(value).toBeCloseTo(0.3); // Floating point
Strings
expect(string).toMatch(/pattern/); expect(string).toMatch('substring'); expect(string).toContain('substring');
Arrays and Iterables
expect(array).toContain('item'); expect(array).toHaveLength(3); expect(array).toEqual(expect.arrayContaining([1, 2]));
Objects
expect(object).toHaveProperty('key'); expect(object).toHaveProperty('key', value); expect(object).toMatchObject({ a: 1, b: 2 }); expect(object).toEqual(expect.objectContaining({ a: 1 }));
Functions
expect(fn).toThrow(); expect(fn).toThrow('error message'); expect(fn).toThrow(Error); expect(fn).toHaveBeenCalled(); expect(fn).toHaveBeenCalledWith(arg1, arg2); expect(fn).toHaveBeenCalledTimes(3);
Testing Checklist
Unit Tests
-
Test pure functions in isolation
-
Test all code paths (happy path and error cases)
-
Test edge cases and boundary conditions
-
Mock external dependencies
-
Achieve high code coverage (>80%)
Integration Tests
-
Test API endpoints
-
Test authentication/authorization
-
Test database operations
-
Test external API integration
-
Test error handling
E2E Tests
-
Test critical user flows
-
Test form submissions
-
Test navigation
-
Test authentication flow
General
-
Tests are fast (< 5 seconds for unit tests)
-
Tests are independent (can run in any order)
-
Tests are repeatable (same result every time)
-
Tests have clear, descriptive names
-
Setup and teardown properly implemented
-
No hardcoded values (use constants/fixtures)
-
CI/CD integration configured
Example Test Suite for Express API
const request = require('supertest'); const app = require('../server'); const db = require('../db');
describe('Express API Tests', () => { // Setup: Connect to test database beforeAll(async () => { await db.connect(process.env.TEST_DATABASE_URL); });
// Cleanup: Disconnect from database afterAll(async () => { await db.disconnect(); });
// Reset database before each test beforeEach(async () => { await db.query('DELETE FROM users'); });
describe('GET /api/health', () => { it('should return health status', async () => { const response = await request(app) .get('/api/health') .expect(200);
expect(response.body).toEqual({
status: 'ok',
timestamp: expect.any(Number)
});
});
});
describe('POST /api/users', () => { it('should create user with valid data', async () => { const userData = { email: 'test@example.com', password: 'SecurePass123!', name: 'Test User' };
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
email: userData.email,
name: userData.name
});
expect(response.body).not.toHaveProperty('password');
});
it('should reject duplicate email', async () => {
const userData = {
email: 'test@example.com',
password: 'SecurePass123!',
name: 'Test User'
};
// Create first user
await request(app).post('/api/users').send(userData);
// Try to create duplicate
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(409);
expect(response.body.error).toMatch(/already exists/i);
});
it('should validate email format', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
password: 'SecurePass123!',
name: 'Test'
})
.expect(400);
expect(response.body.error).toMatch(/email/i);
});
it('should enforce password requirements', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
password: 'weak',
name: 'Test'
})
.expect(400);
expect(response.body.error).toMatch(/password/i);
});
});
describe('Authentication', () => { let authToken; const testUser = { email: 'auth@example.com', password: 'SecurePass123!', name: 'Auth User' };
beforeEach(async () => {
// Create user
await request(app).post('/api/users').send(testUser);
// Login and get token
const response = await request(app)
.post('/api/login')
.send({
email: testUser.email,
password: testUser.password
});
authToken = response.body.token;
});
it('should login with valid credentials', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: testUser.email,
password: testUser.password
})
.expect(200);
expect(response.body).toHaveProperty('token');
});
it('should reject invalid credentials', async () => {
await request(app)
.post('/api/login')
.send({
email: testUser.email,
password: 'WrongPassword'
})
.expect(401);
});
it('should access protected route with token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.email).toBe(testUser.email);
});
it('should reject access without token', async () => {
await request(app)
.get('/api/profile')
.expect(401);
});
}); });
Resources
-
Jest Documentation: https://jestjs.io/docs/getting-started
-
Supertest Documentation: https://github.com/ladjs/supertest
-
Testing Best Practices: https://github.com/goldbergyoni/javascript-testing-best-practices
-
Kent C. Dodds Testing Library: https://testing-library.com/
-
Node.js Testing Best Practices: https://github.com/goldbergyoni/nodebestpractices#6-testing-best-practices