Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Testing with Jest
Testing is non-negotiable for professional software development. Shipping code without tests is like flying a plane without instruments — you might get lucky in clear weather, but when things get turbulent (and they always do), you are flying blind. In this chapter, you’ll learn how to write comprehensive tests for your Node.js applications using Jest and Supertest.
Why Testing Matters
| Benefit | Description |
|---|
| Confidence | Deploy knowing your code works |
| Documentation | Tests describe expected behavior |
| Refactoring | Change code safely with tests as a safety net |
| Bug Prevention | Catch issues before production |
| Design | TDD leads to better architecture |
Testing Pyramid
The testing pyramid is a guideline for how to distribute your testing effort. The idea is simple: tests at the bottom of the pyramid are fast, cheap, and isolated. Tests at the top are slow, expensive, and realistic. You want a lot of the cheap fast ones and just enough of the expensive slow ones to feel confident.
Think of it like quality control at a factory: you check every individual component (unit tests), test how components fit together (integration tests), and occasionally run the whole assembled product through a real-world trial (E2E tests).
/\
/ \ E2E Tests (10%)
/----\ Slow, expensive, fewer
/ \
/--------\ Integration Tests (20%)
/ \ Test component interactions
/------------\
/ \ Unit Tests (70%)
/__________________\ Fast, isolated, many
Setup
npm install --save-dev jest @types/jest supertest
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch", // Re-runs tests when files change -- invaluable during development
"test:coverage": "jest --coverage" // Generates a coverage report showing which lines are untested
},
"jest": {
"testEnvironment": "node", // Use Node.js globals (not browser/jsdom)
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"src/**/*.js",
"!src/**/*.test.js" // Exclude test files from coverage calculation
],
"coverageThreshold": {
"global": {
"branches": 80, // If coverage drops below 80%, the test run fails.
"functions": 80, // This acts as a ratchet -- coverage can only go up,
"lines": 80, // never down, preventing gradual erosion of test quality.
"statements": 80
}
}
}
}
Node.js tip: Use jest --watch during development — it only re-runs tests related to files you have changed, giving you near-instant feedback. For CI/CD pipelines, use jest --ci which disables interactive features and produces machine-readable output.
Writing Your First Test
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
};
module.exports = { add, subtract, multiply, divide };
// math.test.js
const { add, subtract, multiply, divide } = require('./math');
// describe() groups related tests together -- think of it as a chapter heading.
// Nesting describe blocks creates a hierarchy: module > function > scenario.
describe('Math functions', () => {
describe('add', () => {
// test() (aliased as it()) defines a single test case.
// The string should read as a sentence: "it should add two positive numbers."
test('should add two positive numbers', () => {
// expect().toBe() is the core assertion: "I expect this value to be that value."
expect(add(2, 3)).toBe(5);
});
test('should add negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
// Test edge cases -- zero is a common source of bugs (off-by-one, falsy checks)
test('should add zero', () => {
expect(add(5, 0)).toBe(5);
});
});
describe('divide', () => {
test('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
// Testing error cases is just as important as testing success cases.
// Wrap the throwing call in a function so Jest can catch and inspect the error.
test('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
});
});
Jest Matchers
Matchers are the vocabulary of assertions — they let you express exactly what you expect. Choosing the right matcher makes your test failures more descriptive. When a test fails, Jest tells you what the matcher expected vs. what it received, so a precise matcher gives you a precise error message.
describe('Jest Matchers', () => {
// Equality -- toBe() uses Object.is (strict reference equality).
// For primitives this works like ===. For objects, it checks if they
// are the SAME object in memory, not just structurally identical.
test('exact equality', () => {
expect(2 + 2).toBe(4);
});
// toEqual() does a DEEP comparison of structure and values.
// Use it for objects and arrays -- toBe() would fail here because
// the two objects are different references even though they look identical.
test('object equality', () => {
expect({ name: 'John' }).toEqual({ name: 'John' });
});
// Truthiness
test('truthy/falsy', () => {
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('value').toBeDefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
});
// Numbers
test('number comparisons', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
// toBeCloseTo() is essential for floating-point math.
// 0.1 + 0.2 === 0.30000000000000004 in JavaScript (IEEE 754 quirk),
// so toBe(0.3) would FAIL. toBeCloseTo accounts for this rounding.
expect(0.1 + 0.2).toBeCloseTo(0.3);
});
// Strings
test('string matching', () => {
expect('Hello World').toMatch(/World/);
expect('Hello World').toContain('World');
});
// Arrays
test('array matching', () => {
expect([1, 2, 3]).toContain(2);
expect(['a', 'b', 'c']).toHaveLength(3);
expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 });
});
// Objects
test('object matching', () => {
expect({ a: 1, b: 2 }).toHaveProperty('a');
expect({ a: 1, b: 2 }).toHaveProperty('a', 1);
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 });
});
// Exceptions
test('exception matching', () => {
expect(() => { throw new Error('fail'); }).toThrow();
expect(() => { throw new Error('fail'); }).toThrow('fail');
expect(() => { throw new Error('fail'); }).toThrow(Error);
});
});
Testing Async Code
Most Node.js code is asynchronous — database queries, HTTP requests, file I/O. Testing async code requires telling Jest to wait for the operation to complete before checking assertions. There are three patterns, matching the three async styles in JavaScript: async/await, promises, and callbacks. Use whichever matches the code you are testing.
// userService.js
const fetchUser = async (id) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
};
const fetchUserCallback = (id, callback) => {
setTimeout(() => {
if (id === 0) callback(new Error('User not found'), null);
else callback(null, { id, name: 'John' });
}, 100);
};
// userService.test.js
describe('Async Testing', () => {
// Pattern 1: async/await -- the cleanest and most common approach.
// Just mark the test function as async and use await normally.
test('async function success', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('name');
});
// Testing that an async function REJECTS (throws).
// Note: you must await the expect() call -- forgetting the await
// is a classic bug that makes the test always pass.
test('async function error', async () => {
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
// Pattern 2: Promises -- return the promise chain from the test.
// Jest waits for the returned promise to settle before finishing.
test('promise resolves', () => {
return expect(fetchUser(1)).resolves.toHaveProperty('name');
});
// Pattern 3: Callbacks -- use the 'done' parameter.
// Jest will wait until done() is called. If done() is never called
// (e.g., the callback is not invoked), the test times out and fails.
test('callback success', (done) => {
fetchUserCallback(1, (err, user) => {
expect(err).toBeNull();
expect(user.name).toBe('John');
done(); // Signal to Jest that the async work is complete
});
});
});
Common pitfall: Forgetting await before expect(...).rejects.toThrow(). Without the await, the test function returns immediately and Jest considers it passed — the rejection happens after the test finishes. Always await async assertions.
Mocking
Function Mocks
Mocking is like using a stunt double in a movie. You replace a real dependency (database call, HTTP request, email sender) with a fake that you control, so you can test your code in isolation without side effects. The mock records how it was called, letting you verify your code interacted with it correctly.
// jest.fn() creates a "spy" function that records every call
const mockFn = jest.fn();
mockFn('hello');
mockFn('world');
// Verify the mock was called correctly
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
// Control what the mock returns
const mockAdd = jest.fn()
.mockReturnValue(10) // Default return for all calls
.mockReturnValueOnce(5); // Override for just the first call
expect(mockAdd()).toBe(5); // First call returns the "once" value
expect(mockAdd()).toBe(10); // Subsequent calls use the default
// Provide a full implementation for the mock
const mockMultiply = jest.fn((a, b) => a * b);
expect(mockMultiply(3, 4)).toBe(12);
Module Mocks
Module mocks replace an entire imported module with mock versions of its exports. This is essential when testing code that depends on side-effect-heavy modules like email senders, payment processors, or third-party APIs — you do not want your test suite sending real emails or charging real credit cards.
// emailService.js
const sendEmail = async (to, subject, body) => {
// Real implementation sends email via SendGrid, SES, etc.
};
module.exports = { sendEmail };
// user.test.js
// jest.mock() replaces EVERY export of the module with a jest.fn() stub.
// This must be called at the top level, before any imports that use the module.
// Jest hoists it above all imports automatically.
jest.mock('./emailService');
const { sendEmail } = require('./emailService');
const { registerUser } = require('./userService');
describe('User registration', () => {
beforeEach(() => {
// Clear call history between tests -- without this, assertions like
// toHaveBeenCalledTimes(1) would accumulate across tests
sendEmail.mockClear();
// Tell the mock to resolve with a success response
sendEmail.mockResolvedValue({ success: true });
});
test('should send welcome email on registration', async () => {
await registerUser({ email: 'test@example.com', name: 'Test' });
// Verify the email service was called exactly once
expect(sendEmail).toHaveBeenCalledTimes(1);
// Verify it was called with the right arguments.
// expect.stringContaining() and expect.any() are "asymmetric matchers" --
// they let you assert on the shape of arguments without exact matching.
expect(sendEmail).toHaveBeenCalledWith(
'test@example.com',
expect.stringContaining('Welcome'),
expect.any(String)
);
});
});
Mocking Modules
// Automatically mock all module functions
jest.mock('axios');
const axios = require('axios');
test('fetch data', async () => {
axios.get.mockResolvedValue({ data: { id: 1, name: 'John' } });
const user = await getUser(1);
expect(user.name).toBe('John');
expect(axios.get).toHaveBeenCalledWith('/api/users/1');
});
// Manual mock implementation
jest.mock('axios', () => ({
get: jest.fn(() => Promise.resolve({ data: {} })),
post: jest.fn(() => Promise.resolve({ data: {} }))
}));
Testing Express APIs with Supertest
Supertest lets you make HTTP requests against your Express app without starting a real server on a real port. It spins up the app in-process, sends the request, and returns the response — all synchronously within your test. This is much faster and more reliable than starting a server and hitting it with an HTTP client.
The key architectural requirement: your app.js must export the app without calling .listen(). The .listen() call belongs in a separate server.js file. This separation lets Supertest create its own ephemeral server for each test.
npm install --save-dev supertest
// app.js (export app without listening -- this is the critical pattern)
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users', (req, res) => {
res.json([{ id: 1, name: 'John' }]);
});
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
if (id === 999) return res.status(404).json({ error: 'Not found' });
res.json({ id, name: 'John' });
});
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email required' });
}
res.status(201).json({ id: 1, name, email });
});
module.exports = app;
// app.test.js
const request = require('supertest');
const app = require('./app');
describe('User API', () => {
describe('GET /api/users', () => {
test('should return all users', async () => {
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toBeInstanceOf(Array);
expect(response.body.length).toBeGreaterThan(0);
});
});
describe('GET /api/users/:id', () => {
test('should return user by id', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body).toHaveProperty('id', 1);
expect(response.body).toHaveProperty('name');
});
test('should return 404 for non-existent user', async () => {
await request(app)
.get('/api/users/999')
.expect(404);
});
});
describe('POST /api/users', () => {
test('should create new user', async () => {
const newUser = { name: 'Jane', email: 'jane@example.com' };
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201);
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id');
});
test('should return 400 for invalid data', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Jane' }) // Missing email
.expect(400);
});
});
});
Database Testing
Database tests are where the rubber meets the road. You need a real database to test queries, validations, and constraints — but you do not want tests touching your development or production data. The solution is an in-memory database that spins up fresh for each test run and disappears when tests finish.
mongodb-memory-server downloads and runs a real MongoDB binary in memory. It is not a mock — it is the actual MongoDB engine, just running in a disposable sandbox. This means your tests exercise real database behavior, including indexes, validators, and aggregation pipelines.
// Setup test database
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
beforeAll(async () => {
// Spin up an isolated MongoDB instance -- takes 1-2 seconds on first run
// (it caches the binary after the first download)
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
// Tear down cleanly -- always disconnect before stopping the server
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
// Clear all collections before each test to ensure test isolation.
// Without this, data from one test leaks into the next, causing
// flaky failures that are extremely hard to debug.
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
describe('User Model', () => {
test('should create user', async () => {
const user = await User.create({
name: 'John',
email: 'john@example.com',
password: 'password123'
});
expect(user._id).toBeDefined();
expect(user.name).toBe('John');
expect(user.password).not.toBe('password123'); // Should be hashed
});
test('should not create user without email', async () => {
await expect(
User.create({ name: 'John', password: 'password123' })
).rejects.toThrow();
});
});
Test Coverage
Coverage reports tell you which lines, branches, and functions in your codebase are exercised by your tests. They are a useful diagnostic tool, but they are not a quality metric — 100% coverage does not mean your tests are good, it just means every line was executed. You can hit 100% coverage with meaningless assertions. Coverage tells you what is NOT tested (which is very valuable); it cannot tell you whether what IS tested is tested well.
A good rule of thumb: aim for 80%+ line coverage on critical paths (auth, payment, data validation) and do not stress about getting peripheral utilities to 100%.
# Generate coverage report
npm test -- --coverage
# Output shows four metrics per file:
# % Stmts -- percentage of statements executed
# % Branch -- percentage of if/else branches taken (the hardest to get high)
# % Funcs -- percentage of functions called at least once
# % Lines -- percentage of executable lines hit
# Uncovered -- exact line numbers with zero coverage (your to-do list)
# ----------|---------|----------|---------|---------|-------------------
# File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
# ----------|---------|----------|---------|---------|-------------------
# All files | 85.71 | 83.33 | 100 | 85.71 |
# math.js | 100 | 100 | 100 | 100 |
# user.js | 71.43 | 66.67 | 100 | 71.43 | 15-17
# ----------|---------|----------|---------|---------|-------------------
Node.js tip: Add coverage/ to your .gitignore — coverage reports are generated artifacts, not source code. In CI/CD, use a service like Codecov or Coveralls to track coverage trends over time and catch regressions in pull requests.
Best Practices
- Name tests clearly — A test name should read as a specification: “should return 404 when user does not exist.” When it fails, the name alone should tell you what broke.
- One assertion per test — Keep tests focused on a single behavior. Multiple assertions are fine if they all verify the same logical outcome, but testing two unrelated things in one test makes failures ambiguous.
- Use beforeEach/afterEach — Ensure each test starts with a clean state. Without this, tests become order-dependent — they pass when run alone but fail in sequence (the most frustrating kind of flaky test).
- Don’t test implementation — Test what the function does (behavior), not how it does it (internals). If you refactor the implementation, tests should not break unless the behavior changes.
- Mock external services — Don’t make real API calls, send real emails, or hit real databases in unit tests. Mocks keep tests fast, deterministic, and free of network dependencies.
- Aim for 80%+ coverage — But 100% is not always the goal. Focus coverage on critical business logic, edge cases, and error paths. Trivial getters and setters rarely need tests.
- Run tests in CI/CD — Every push should trigger the full test suite. A test that only runs on the developer’s machine is a test that will eventually be ignored.
Test Organization
There are two common approaches to organizing test files, and both are valid. Choose one and be consistent:
Co-location places the test file next to the source file it tests. This makes it easy to find the test for any given file and encourages developers to write tests alongside their code. It is the most popular pattern in the Node.js ecosystem.
Separate directory places all tests in a __tests__/ folder, mirroring the source structure. This keeps the source tree clean and makes it easy to configure different tooling for test files.
src/
├── services/
│ ├── userService.js
│ └── userService.test.js # Co-located: test lives next to source
├── models/
│ └── User.js
└── __tests__/ # Separate: organized by test type
├── integration/
│ └── userApi.test.js # Tests that hit real endpoints
└── unit/
└── userModel.test.js # Tests that run in isolation
Practical pattern: Use co-located files for unit tests (they test a single module) and a separate __tests__/integration/ directory for integration tests (they test multiple modules working together). This way, jest --testPathPattern=__tests__/integration lets you run integration tests separately from unit tests — useful because integration tests are slower and may require a running database.
Summary
- Jest is the most popular testing framework for Node.js
- Use describe/test to organize tests hierarchically
- Matchers like
toBe, toEqual, toThrow validate expectations
- Mock external dependencies to isolate tests
- Supertest makes API testing straightforward
- Use in-memory databases for database tests
- Aim for high coverage but prioritize critical paths
- Write tests before or alongside code, not as an afterthought