Unit Testing with Jest and Supertest
Testing is the discipline that converts 'I think this works' into 'I can prove this works'. In Node.js backend development, Jest is the dominant test runner and assertion library, while Supertest is the standard tool for making HTTP requests against an Express app in tests without starting a real server. Together they cover two essential testing layers: unit tests that verify individual functions in isolation, and integration tests that verify your Express routes handle requests and produce correct responses end-to-end.
Setting Up Jest
Jest is a zero-configuration test runner for Node.js. Install it as a dev dependency, add a test script to package.json, and Jest will automatically discover any file ending in .test.js or .spec.js, or any file inside a __tests__ directory.
npm install --save-dev jest supertest{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"coverageThreshold": {
"global": { "branches": 70, "functions": 80, "lines": 80 }
}
}
}Writing Unit Tests
A unit test isolates a single function and tests it against defined inputs and expected outputs. Use describe() to group related tests and it() or test() for individual cases. Jest's expect() API provides matchers: toBe (strict equality), toEqual (deep equality), toThrow, toHaveBeenCalledWith, and many more.
// utils/paginate.js
function paginate(array, page = 1, limit = 10) {
const total = array.length;
const pages = Math.ceil(total / limit);
return { data: array.slice((page - 1) * limit, page * limit), total, page, pages, limit };
}
module.exports = { paginate };
// utils/paginate.test.js
const { paginate } = require('./paginate');
describe('paginate()', () => {
const items = Array.from({ length: 25 }, (_, i) => i + 1);
it('returns the first page with default limit 10', () => {
const result = paginate(items);
expect(result.data).toHaveLength(10);
expect(result.data[0]).toBe(1);
expect(result.page).toBe(1);
expect(result.pages).toBe(3);
expect(result.total).toBe(25);
});
it('returns the correct slice for page 2', () => {
const result = paginate(items, 2, 10);
expect(result.data[0]).toBe(11);
expect(result.data[9]).toBe(20);
});
it('returns a partial last page', () => {
const result = paginate(items, 3, 10);
expect(result.data).toHaveLength(5);
expect(result.data[4]).toBe(25);
});
it('returns empty data for a page beyond total', () => {
const result = paginate(items, 10, 10);
expect(result.data).toHaveLength(0);
});
});Mocking Dependencies
Unit tests must be isolated: if your function calls a database, an email service, or a third-party API, those calls must be replaced with mocks. Jest provides jest.mock() for module mocking, jest.fn() for creating spy functions, and jest.spyOn() for watching real implementations. Mocking ensures tests are fast, deterministic, and don't require external services.
// services/userService.js
const User = require('../models/User');
const { sendWelcomeEmail } = require('../utils/email');
async function registerUser(email, password) {
const existing = await User.findOne({ email });
if (existing) throw new Error('Email already exists');
const user = await User.create({ email, passwordHash: 'hashed' });
await sendWelcomeEmail({ to: email, name: email.split('@')[0] });
return user;
}
module.exports = { registerUser };
// services/userService.test.js
jest.mock('../models/User');
jest.mock('../utils/email');
const User = require('../models/User');
const { sendWelcomeEmail } = require('../utils/email');
const { registerUser } = require('./userService');
describe('registerUser()', () => {
beforeEach(() => jest.clearAllMocks());
it('creates a user and sends welcome email', async () => {
User.findOne.mockResolvedValue(null); // no existing user
User.create.mockResolvedValue({ id: '1', email: '[email protected]' });
sendWelcomeEmail.mockResolvedValue(undefined);
const user = await registerUser('[email protected]', 'password123');
expect(User.create).toHaveBeenCalledWith(
expect.objectContaining({ email: '[email protected]' })
);
expect(sendWelcomeEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: '[email protected]' })
);
expect(user.email).toBe('[email protected]');
});
it('throws if email already exists', async () => {
User.findOne.mockResolvedValue({ id: '1', email: '[email protected]' });
await expect(registerUser('[email protected]', 'pass')).rejects.toThrow('Email already exists');
expect(User.create).not.toHaveBeenCalled();
});
});Always call jest.clearAllMocks() or jest.resetAllMocks() in a beforeEach block to prevent mock state from leaking between tests. Stale mock return values or call counts from a previous test will produce false positives.
HTTP Route Testing with Supertest
Supertest wraps your Express app and lets you make real HTTP calls in tests without binding to a port. Import your app (not server.js — the file that calls app.listen), pass it to supertest's request(), and chain HTTP method calls with assertions. Supertest handles opening and closing the HTTP connection automatically.
// app.js — export app WITHOUT calling app.listen()
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/products', require('./routes/products'));
module.exports = app; // no app.listen() here
// routes/products.test.js
const request = require('supertest');
const app = require('../app');
jest.mock('../models/Product');
const Product = require('../models/Product');
describe('GET /api/products', () => {
it('returns a list of products with 200', async () => {
Product.find.mockResolvedValue([
{ id: '1', name: 'Cricket Bat', price: 120 },
{ id: '2', name: 'Helmet', price: 85 }
]);
const res = await request(app).get('/api/products');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].name).toBe('Cricket Bat');
});
it('returns 500 when database throws', async () => {
Product.find.mockRejectedValue(new Error('DB connection lost'));
const res = await request(app).get('/api/products');
expect(res.statusCode).toBe(500);
});
});
describe('POST /api/products', () => {
it('creates a product and returns 201', async () => {
const newProduct = { id: '3', name: 'Gloves', price: 45 };
Product.create.mockResolvedValue(newProduct);
const res = await request(app)
.post('/api/products')
.set('Authorization', 'Bearer valid-test-token')
.send({ name: 'Gloves', price: 45 });
expect(res.statusCode).toBe(201);
expect(res.body.name).toBe('Gloves');
});
it('returns 400 for missing required fields', async () => {
const res = await request(app)
.post('/api/products')
.set('Authorization', 'Bearer valid-test-token')
.send({});
expect(res.statusCode).toBe(400);
});
});