Testing Node.js APIs with Jest & Supertest

Testing Node.js APIs with Jest & Supertest
An untested codebase is a liability. Every change carries the risk of silently breaking something, and manual testing does not scale. Automated tests give you the confidence to refactor, add features, and deploy — knowing that regressions are caught immediately.
This module covers unit testing services and middleware with Jest, integration testing Express endpoints with Supertest, testing authentication flows, mocking external dependencies, and measuring coverage.
This is Module 27 of the Node.js Full‑Stack Developer course.
Installing Dependencies
npm install --save-dev jest supertest @jest/globals
# For ESM support:
npm install --save-dev babel-jest @babel/core @babel/preset-env
# For MongoDB in-memory:
npm install --save-dev mongodb-memory-serverJest Configuration
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/*.test.js"],
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"src/**/*.js",
"!src/server.js",
"!src/config/**"
],
"coverageThreshold": {
"global": { "branches": 70, "functions": 80, "lines": 80 }
},
"setupFilesAfterFramework": ["./tests/setup.js"]
}
}Test Setup — In-Memory MongoDB
// tests/setup.js
import { MongoMemoryServer } from 'mongodb-memory-server';
import mongoose from 'mongoose';
let mongod;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
afterEach(async () => {
// Clear all collections between tests
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});Unit Testing — Service Layer
// features/users/users.service.test.js
import { createUser, getUserById } from './users.service.js';
import { User } from './users.model.js';
describe('Users Service', () => {
describe('createUser', () => {
test('creates a user and returns sanitized data', async () => {
const user = await createUser({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
expect(user).toMatchObject({
name: 'Alice',
email: 'alice@example.com',
});
expect(user.password).toBeUndefined(); // never returned
});
test('throws 409 if email already exists', async () => {
await createUser({ name: 'Alice', email: 'alice@example.com', password: 'pw1' });
await expect(
createUser({ name: 'Bob', email: 'alice@example.com', password: 'pw2' })
).rejects.toMatchObject({ statusCode: 409 });
});
test('hashes the password before storing', async () => {
await createUser({ name: 'Alice', email: 'alice@example.com', password: 'plaintext' });
const stored = await User.findOne({ email: 'alice@example.com' }).select('+password');
expect(stored.password).not.toBe('plaintext');
expect(stored.password).toMatch(/^\$2b\$/); // bcrypt prefix
});
});
describe('getUserById', () => {
test('returns the user for a valid id', async () => {
const created = await createUser({ name: 'Bob', email: 'bob@example.com', password: 'pw' });
const found = await getUserById(created.id);
expect(found.email).toBe('bob@example.com');
});
test('throws 404 for unknown id', async () => {
await expect(
getUserById('000000000000000000000000')
).rejects.toMatchObject({ statusCode: 404 });
});
});
});Unit Testing — Middleware
// middleware/rbac.test.js
import { requireRole } from './rbac.js';
function mockNext() {
return jest.fn();
}
function mockReq(role) {
return { user: { sub: 'user-123', role } };
}
describe('requireRole middleware', () => {
test('calls next() with no args when role is sufficient', () => {
const req = mockReq('admin');
const next = mockNext();
requireRole('editor')(req, {}, next);
expect(next).toHaveBeenCalledWith();
});
test('calls next(error) with 403 when role is insufficient', () => {
const req = mockReq('user');
const next = mockNext();
requireRole('editor')(req, {}, next);
expect(next).toHaveBeenCalledWith(
expect.objectContaining({ statusCode: 403 })
);
});
test('calls next(error) with 401 when user is not attached', () => {
const next = mockNext();
requireRole('user')({}, {}, next);
expect(next).toHaveBeenCalledWith(
expect.objectContaining({ statusCode: 401 })
);
});
});Integration Testing — Express Routes with Supertest
// features/auth/auth.test.js
import request from 'supertest';
import app from '../../app.js';
import { User } from '../users/users.model.js';
import { hashPassword } from '../../lib/crypto.js';
describe('Auth API', () => {
describe('POST /api/v1/auth/register', () => {
test('registers a new user and returns access token', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({ name: 'Alice', email: 'alice@example.com', password: 'password123' });
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('accessToken');
expect(res.body.user).toMatchObject({ name: 'Alice', email: 'alice@example.com' });
expect(res.body.user.password).toBeUndefined();
});
test('returns 409 for duplicate email', async () => {
await request(app)
.post('/api/v1/auth/register')
.send({ name: 'Alice', email: 'alice@example.com', password: 'password123' });
const res = await request(app)
.post('/api/v1/auth/register')
.send({ name: 'Alice2', email: 'alice@example.com', password: 'password456' });
expect(res.status).toBe(409);
});
test('returns 422 for invalid email', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({ name: 'Alice', email: 'not-an-email', password: 'password123' });
expect(res.status).toBe(422);
expect(res.body.errors[0].field).toBe('email');
});
});
describe('POST /api/v1/auth/login', () => {
beforeEach(async () => {
await User.create({
name: 'Bob',
email: 'bob@example.com',
password: await hashPassword('correctpassword'),
});
});
test('returns access token for valid credentials', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'bob@example.com', password: 'correctpassword' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('accessToken');
});
test('returns 401 for wrong password', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'bob@example.com', password: 'wrongpassword' });
expect(res.status).toBe(401);
});
test('returns 401 for unknown email', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'nobody@example.com', password: 'password' });
expect(res.status).toBe(401);
});
});
});Testing Protected Routes
// tests/helpers/auth.js
import { signAccessToken } from '../../lib/tokens.js';
export function getAuthHeader(overrides = {}) {
const payload = { sub: 'test-user-id', role: 'user', version: 0, ...overrides };
return { Authorization: `Bearer ${signAccessToken(payload)}` };
}
export function getAdminHeader() {
return getAuthHeader({ role: 'admin' });
}// features/users/users.test.js
import request from 'supertest';
import app from '../../app.js';
import { User } from './users.model.js';
import { getAuthHeader, getAdminHeader } from '../../../tests/helpers/auth.js';
describe('Users API', () => {
describe('GET /api/v1/users', () => {
test('returns 401 without auth', async () => {
const res = await request(app).get('/api/v1/users');
expect(res.status).toBe(401);
});
test('returns 403 for non-admin', async () => {
const res = await request(app)
.get('/api/v1/users')
.set(getAuthHeader({ role: 'user' }));
expect(res.status).toBe(403);
});
test('returns user list for admin', async () => {
await User.create([
{ name: 'User 1', email: 'u1@example.com', password: 'pw' },
{ name: 'User 2', email: 'u2@example.com', password: 'pw' },
]);
const res = await request(app)
.get('/api/v1/users')
.set(getAdminHeader());
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(2);
});
});
});Mocking External Dependencies
// Mock Redis to prevent test failures when Redis is not running
jest.mock('../../lib/redis.js', () => ({
default: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue('OK'),
del: jest.fn().mockResolvedValue(1),
scanStream: jest.fn().mockReturnValue({
on: jest.fn(),
}),
pipeline: jest.fn().mockReturnValue({ del: jest.fn(), exec: jest.fn() }),
},
}));
// Mock email service
jest.mock('../../lib/email.js', () => ({
sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
sendPasswordReset: jest.fn().mockResolvedValue(undefined),
}));// Verify a mock was called with specific arguments
import { sendWelcomeEmail } from '../../lib/email.js';
test('sends welcome email on registration', async () => {
await request(app)
.post('/api/v1/auth/register')
.send({ name: 'Alice', email: 'alice@example.com', password: 'password123' });
expect(sendWelcomeEmail).toHaveBeenCalledWith(
expect.objectContaining({ email: 'alice@example.com' })
);
});Test Organisation
tests/
setup.js ← global beforeAll/afterAll/afterEach
helpers/
auth.js ← getAuthHeader, getAdminHeader
seed.js ← createTestUser, createTestWorkspace
src/
features/
auth/
auth.test.js ← integration tests for auth routes
users/
users.service.test.js ← unit tests for users service
users.test.js ← integration tests for users routes
middleware/
rbac.test.js ← unit tests for middleware
validate.test.jsRunning Tests
# Run all tests once
npm test
# Watch mode — re-runs affected tests on file change
npm run test:watch
# Coverage report
npm run test:coverage
# Run a specific test file
npx jest features/auth/auth.test.js
# Run tests matching a pattern
npx jest --testNamePattern="returns 401"Node.js Full‑Stack Course — Module 27 of 32
Your API now has a solid test suite. Continue to Module 28 to learn TDD, mocking, and spying techniques.
Summary
A well-tested Node.js API uses both unit and integration tests:
- Unit tests test services and middleware in isolation — fast, precise, no database needed
- Integration tests use Supertest to make real HTTP requests through the full Express stack
mongodb-memory-serverspins up an in-memory MongoDB for each test run — no shared state between test runs- The
afterEachhook clears all collections so tests are fully independent - A
getAuthHeader()helper generates valid JWTs for test users without hitting the database jest.mock()replaces Redis, email clients, and external APIs with no-op stubs- Coverage thresholds in
jestconfig enforce minimum coverage on CI — PRs that drop below fail the build
Continue to Module 28: TDD, Mocking & Spying →
