Testing
Testing Node.js apps with Jest and Supertest — unit tests, integration tests, mock functions, lifecycle hooks, and coverage reporting.
Jest Setup & Configuration
Install Jest and configure it in package.json. Use --forceExit to prevent hanging when DB connections are open during tests.
npm install --save-dev jest supertest
// package.json
{
"scripts": {
"test": "jest --forceExit",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage --forceExit"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/__tests__/**/*.test.js"],
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"src/**/*.js",
"!src/migrations/**",
"!src/seeders/**"
],
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 80,
"lines": 80,
"statements": 80
}
},
"setupFilesAfterFramework": ["./jest.setup.js"]
}
}Unit Tests with Jest
Test pure functions in isolation. Use describe to group related tests and it/test for individual assertions. Use beforeEach/afterEach for setup and teardown.
// __tests__/userService.test.js
const userService = require('../src/services/userService');
const db = require('../src/db');
// Mock the database module entirely
jest.mock('../src/db');
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks(); // reset call counts before each test
});
describe('getUserById()', () => {
it('returns the user when found', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
db.query.mockResolvedValueOnce([[mockUser]]);
const user = await userService.getUserById(1);
expect(db.query).toHaveBeenCalledTimes(1);
expect(db.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT'), [1]
);
expect(user).toEqual(mockUser);
});
it('returns null when user does not exist', async () => {
db.query.mockResolvedValueOnce([[]]); // empty result
const user = await userService.getUserById(999);
expect(user).toBeNull();
});
it('throws if DB query fails', async () => {
db.query.mockRejectedValueOnce(new Error('DB connection lost'));
await expect(userService.getUserById(1)).rejects.toThrow('DB connection lost');
});
});
describe('createUser()', () => {
it('hashes password and inserts user', async () => {
db.query.mockResolvedValueOnce([{ insertId: 42 }]);
const result = await userService.createUser({
name: 'Bob', email: 'bob@example.com', password: 'secret',
});
expect(result.id).toBe(42);
expect(db.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT'),
expect.arrayContaining(['Bob', 'bob@example.com'])
);
});
});
});Mock Functions & Spies
Use jest.fn() for standalone mocks and jest.spyOn() to wrap existing methods. Spies can restore the original implementation after the test.
const mailer = require('../src/lib/mailer');
// ── jest.fn() – create a mock from scratch ────────────────────
const sendEmail = jest.fn().mockResolvedValue({ messageId: 'abc123' });
// Check invocations
sendEmail('a@b.com', 'Hello');
expect(sendEmail).toHaveBeenCalledWith('a@b.com', 'Hello');
expect(sendEmail).toHaveBeenCalledTimes(1);
// Return different values per call
sendEmail
.mockResolvedValueOnce('first call result')
.mockResolvedValueOnce('second call result');
// ── jest.spyOn() – wrap a real method ─────────────────────────
describe('with spies', () => {
let sendSpy;
beforeEach(() => {
sendSpy = jest.spyOn(mailer, 'sendWelcome').mockResolvedValue(true);
});
afterEach(() => {
sendSpy.mockRestore(); // restore original implementation
});
it('sends welcome email on registration', async () => {
await userService.register({ email: 'x@y.com', password: 'pw' });
expect(sendSpy).toHaveBeenCalledWith('x@y.com');
});
});API Integration Tests with Supertest
Supertest mounts your Express app without starting a real HTTP server. Test the full request/response cycle including middleware, validation, and JSON shape.
// __tests__/api/products.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/db');
jest.mock('../../src/db');
describe('GET /api/products', () => {
const mockProducts = [
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 29.99 },
];
beforeEach(() => {
db.query.mockResolvedValue([mockProducts]);
});
it('returns 200 with product list', async () => {
const res = await request(app).get('/api/products');
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(2);
expect(res.body.data[0]).toMatchObject({ name: 'Widget' });
});
it('supports ?q= search', async () => {
db.query.mockResolvedValueOnce([[mockProducts[0]]]);
const res = await request(app).get('/api/products?q=widget');
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
});
});
describe('POST /api/products', () => {
it('creates product and returns 201', async () => {
db.query.mockResolvedValueOnce([{ insertId: 99 }]);
const res = await request(app)
.post('/api/products')
.set('Authorization', 'Bearer test-token')
.send({ name: 'New Product', price: 49.99 });
expect(res.status).toBe(201);
expect(res.body.id).toBe(99);
});
it('returns 400 for missing required fields', async () => {
const res = await request(app)
.post('/api/products')
.set('Authorization', 'Bearer test-token')
.send({});
expect(res.status).toBe(400);
expect(res.body.code).toBe('BAD_REQUEST');
});
});Test Coverage Report
Run npm run test:coverage to generate an HTML report in /coverage. The coverageThreshold in Jest config enforces minimum thresholds and fails the CI pipeline if not met.
# Generate coverage report
npm run test:coverage
# Output example:
# ----------------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# src/services/ | 92.31 | 85.71 | 100 | 92.31 |
# userService.js | 92.31 | 85.71 | 100 | 92.31 |
# src/middleware/ | 100 | 100 | 100 | 100 |
# errorHandler.js | 100 | 100 | 100 | 100 |
# ----------------------|---------|----------|---------|---------|
# Open HTML report
start coverage/lcov-report/index.html # Windows
open coverage/lcov-report/index.html # macOS