Node.js Testing: Jest, Mocha, and Supertest
Key takeaways
Test Node.js apps with Jest matchers and mocks, async tests, Supertest for Express APIs, MongoDB memory server, integration tests, coverage thresholds, Mocha/Chai, and TDD patterns.
Introduction
Why test?
Testing verifies that code behaves as intended, and in a Node.js service the hard part is usually not the assertions but the I/O around them: HTTP requests, databases, email and payment calls. This post uses Jest for unit tests, matchers, mocks and async code, Supertest for Express API tests, and an in-memory MongoDB for integration tests, then compares Mocha + Chai, looks at coverage thresholds and test doubles, and covers the common failures (timeouts, shared state, leftover data).
Kinds of tests:
- Unit: Single functions or classes
- Integration: Multiple modules together
- E2E: Full system paths
These three kinds sit on a real tradeoff, not just a naming taxonomy: unit tests are fast and pinpoint exactly which function broke, but they can pass while the system as a whole is broken (each piece works in isolation, but the wiring between them doesn’t); E2E tests catch that wiring but are slow, brittle to unrelated changes (a CSS selector rename breaks an E2E test that has nothing to do with styling), and expensive to run on every commit. Integration tests sit deliberately in between — testing a slice of real modules together (an Express route hitting a real, in-memory database, as covered later in this guide) without the full overhead of driving an actual browser. Most healthy test suites lean heavily unit, moderately integration, and sparingly E2E — often visualized as a pyramid — specifically because of this speed/confidence tradeoff.
Jest
Install
npm install --save-dev jest
package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
First tests
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
// math.test.js
const { add, subtract } = require('./math');
describe('Math', () => {
test('add sums two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
test('subtract subtracts two numbers', () => {
expect(subtract(5, 3)).toBe(2);
expect(subtract(1, 1)).toBe(0);
expect(subtract(0, 5)).toBe(-5);
});
});
npm test
describe is purely organizational — it groups related tests and gives them a shared label in output, but it has no effect on how tests run. test (or its alias it) is the actual unit Jest executes and reports pass/fail on independently; nesting describe blocks is just nested labeling for readability in large suites, not a scoping mechanism the way a function body would be. Each test block here checks multiple related inputs (add(2, 3), add(-1, 1), add(0, 0)) rather than splitting into three separate tests — a reasonable choice for a function this simple, though the “one main assertion per test” guideline mentioned later in this guide becomes more valuable as the function under test gets more complex and a single failing assertion needs to point unambiguously at what broke.
Matchers
describe('Matcher examples', () => {
test('equality', () => {
expect(2 + 2).toBe(4);
expect({ name: 'Alice' }).toEqual({ name: 'Alice' });
});
test('truthiness', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('hello').toBeDefined();
});
test('numbers', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(0.1 + 0.2).toBeCloseTo(0.3);
});
test('strings', () => {
expect('hello world').toMatch(/world/);
expect('hello').toContain('ell');
});
test('arrays and objects', () => {
const arr = ['apple', 'banana', 'cherry'];
expect(arr).toContain('banana');
expect(arr).toHaveLength(3);
const obj = { name: 'Alice', age: 25 };
expect(obj).toHaveProperty('name');
expect(obj).toHaveProperty('age', 25);
});
test('exceptions', () => {
expect(() => {
throw new Error('oops');
}).toThrow('oops');
});
});
The toBe vs toEqual distinction is the single most common matcher mistake for anyone new to Jest: toBe uses Object.is — strict reference equality for objects and arrays — so expect({ name: 'Alice' }).toBe({ name: 'Alice' }) would fail even though the two objects look identical, because they’re different object instances. toEqual instead does a recursive structural comparison of contents, which is what you almost always want when asserting on an object or array’s shape rather than its exact identity; toBe stays correct for primitives (numbers, strings, booleans) precisely because primitive equality already means value equality. toThrow deserves a note too: the function under test has to be passed as a callback (() => { throw ... }), not called directly (expect(fn()).toThrow(...)) — calling it directly would throw immediately during the expect() call itself, before Jest’s toThrow machinery ever gets a chance to catch it.
Async tests
Promises
// api.js
async function fetchUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
return response.json();
}
module.exports = { fetchUser };
// api.test.js
const { fetchUser } = require('./api');
describe('API', () => {
test('fetches a user', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('id', 1);
expect(user).toHaveProperty('name');
});
test('missing user rejects', async () => {
await expect(fetchUser(999)).rejects.toThrow();
});
});
await expect(fetchUser(999)).rejects.toThrow() is the idiomatic way to assert a promise rejects, and it’s worth contrasting with the common but subtly broken alternative of wrapping a call in a manual try/catch and asserting inside the catch block — that pattern silently passes if the promise doesn’t reject at all (the catch never runs, and without an explicit fail()/assertion count check, the test reports green with zero assertions actually made). .rejects unwraps the rejection for you and fails the test outright if the promise instead resolves, closing that gap.
Setup and teardown
describe('Database tests', () => {
beforeAll(async () => {
await connectDatabase();
});
beforeEach(async () => {
await clearDatabase();
});
afterEach(async () => {
/* cleanup */
});
afterAll(async () => {
await closeDatabase();
});
test('creates a user', async () => {
const user = await User.create({ name: 'Alice' });
expect(user).toHaveProperty('_id');
});
});
The four hooks run at genuinely different scopes, and mixing them up is a common source of flaky or slow test suites: beforeAll/afterAll run once for the entire describe block (right for expensive setup like opening a database connection), while beforeEach/afterEach run once per individual test (right for resetting state so tests don’t leak into each other). A frequent mistake is putting per-test cleanup — like clearDatabase() — in beforeAll instead of beforeEach: it would only clear the database once before the very first test, leaving every subsequent test seeing whatever data the previous tests left behind, which produces exactly the kind of order-dependent, hard-to-debug test failures that isolated tests are supposed to prevent.
Mocks and spies
Mock functions
describe('Mock functions', () => {
test('tracks calls', () => {
const mockFn = jest.fn();
mockFn('hello');
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
});
test('return values', () => {
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
expect(mockFn()).toBe(3);
expect(mockFn()).toBe(3);
});
});
A mock function tracks every call made to it (arguments, call count, call order) in addition to whatever behavior you configure, which is what makes toHaveBeenCalledWith/toHaveBeenCalledTimes possible — it’s simultaneously a stand-in implementation and a recording of how it was used. mockReturnValueOnce, chained multiple times, queues up a sequence of one-time return values that get consumed in order before falling back to the default set by mockReturnValue — genuinely useful for testing code that calls the same dependency multiple times and needs to see a different result each call (like a retry loop that fails twice then succeeds).
Module mocks
// user.js
const axios = require('axios');
async function getUser(id) {
const response = await axios.get(`https://api.example.com/users/${id}`);
return response.data;
}
module.exports = { getUser };
// user.test.js
const axios = require('axios');
const { getUser } = require('./user');
jest.mock('axios');
describe('getUser', () => {
test('returns user data', async () => {
const mockUser = { id: 1, name: 'Alice' };
axios.get.mockResolvedValue({ data: mockUser });
const user = await getUser(1);
expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
expect(user).toEqual(mockUser);
});
test('propagates errors', async () => {
axios.get.mockRejectedValue(new Error('Network Error'));
await expect(getUser(1)).rejects.toThrow('Network Error');
});
});
jest.mock('axios') replaces the entire module with an auto-mocked version — every exported function becomes a jest.fn() returning undefined by default — which is why axios.get.mockResolvedValue(...) is necessary before the code under test runs: without it, getUser’s await axios.get(...) would resolve to undefined, and destructuring .data off that would throw. This is exactly the “coupled to the HTTP library” limitation the MSW guide elsewhere on this site discusses in more depth — jest.mock('axios') only intercepts code that literally imports axios, so switching the implementation to fetch later would silently break every test that mocks this way, since there’d be nothing left to mock.
Spies
describe('Spies', () => {
test('wraps a method', () => {
const obj = { method: () => 'original' };
const spy = jest.spyOn(obj, 'method');
obj.method();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
The distinction between a spy and a full mock matters: jest.spyOn wraps a real method on a real object, so by default the actual method still executes when called (the spy just observes and records the call) — this is the right tool for “I want to verify this real function got called correctly” without changing its behavior. mockRestore() at the end is not optional cleanup to skip: spying replaces the object’s method with a wrapped version for the duration of the test, and without restoring it, that wrapped version can leak into other tests that share the same object, another instance of the cross-test state-leakage problem the setup/teardown hooks exist to prevent.
API tests with Supertest
npm install --save-dev supertest
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
res.status(201).json({ id: 1, name, email });
});
module.exports = app;
Note that app.js exports the Express app object without ever calling app.listen() — that’s deliberate and is what makes Supertest testing possible without binding to a real port. Supertest takes the app object directly and drives real HTTP requests against it in-process, spinning up an ephemeral server internally per request and tearing it down automatically; this is meaningfully different from, and faster/more reliable than, starting the app for real and pointing a separate HTTP client at localhost:3000, since there’s no port-conflict risk and no need to wait for a server to actually finish booting between test runs.
// app.test.js
const request = require('supertest');
const app = require('./app');
describe('API', () => {
describe('GET /api/users', () => {
test('returns users', async () => {
const response = await request(app)
.get('/api/users')
.expect(200)
.expect('Content-Type', /json/);
expect(response.body).toHaveProperty('users');
expect(Array.isArray(response.body.users)).toBe(true);
});
});
describe('POST /api/users', () => {
test('creates a user', async () => {
const newUser = { name: 'Alice', email: '[email protected]' };
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201)
.expect('Content-Type', /json/);
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id');
});
test('returns 400 when invalid', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Alice' })
.expect(400);
expect(response.body).toHaveProperty('error');
});
});
});
.expect(201)/.expect('Content-Type', /json/) chained onto the request build the assertion into the request itself, failing the test immediately with a clear message (“expected 200, got 400”) the moment the response comes back, rather than requiring separate expect() calls after the fact — a genuinely convenient shortcut for the most common HTTP-level checks. The 400 test at the bottom is worth flagging as the kind of test that’s easy to skip but shouldn’t be: it’s not testing that the API works, it’s testing that the API correctly rejects bad input, which is exactly the category of behavior most likely to have a bug (an unchecked field, a missing validation branch) that only a dedicated failure-path test catches.
Database tests
MongoDB setup
// test/setup.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany();
}
});
jest.config.js:
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
coveragePathIgnorePatterns: ['/node_modules/']
};
mongodb-memory-server spins up a real, temporary MongoDB instance in memory for the test run rather than mocking the database layer — this is a deliberate and meaningful design choice: mocking Mongoose’s query methods would only verify your code calls the right methods, not that a real MongoDB would actually accept your schema, enforce your unique indexes, or run your queries correctly. Testing against a genuine (if ephemeral) database catches an entire category of bugs — schema validation errors, incorrect index behavior, query syntax mistakes — that a mocked ODM layer can’t. The afterEach collection-clearing here is what keeps each test’s database state isolated: without it, a duplicate email fails test running after a test that already created that same email would pass for the wrong reason (leftover state from a previous test), not because the actual duplicate-check logic under test worked.
Model tests
// models/User.test.js
const User = require('../models/User');
describe('User model', () => {
test('creates a user', async () => {
const userData = {
name: 'Alice',
email: '[email protected]',
password: 'password123'
};
const user = await User.create(userData);
expect(user).toHaveProperty('_id');
expect(user.name).toBe(userData.name);
expect(user.email).toBe(userData.email);
});
test('duplicate email fails', async () => {
await User.create({
name: 'Alice',
email: '[email protected]',
password: 'password123'
});
await expect(User.create({
name: 'Bob',
email: '[email protected]',
password: 'password456'
})).rejects.toThrow();
});
test('required fields enforced', async () => {
await expect(User.create({ name: 'Alice' })).rejects.toThrow();
});
});
These three tests together verify the model’s contract rather than just its happy path: creation with valid data, rejection of a uniqueness constraint violation, and rejection of missing required fields. This maps directly onto what a Mongoose schema actually declares (required: true, unique: true on fields), and testing it at the model layer — rather than only through the higher-level API — means a schema regression gets caught with a fast, focused test failure instead of surfacing later as a confusing integration or E2E test failure several layers removed from the actual cause.
Integration tests
// test/integration/users.test.js
const request = require('supertest');
const app = require('../../app');
const User = require('../../models/User');
describe('Users API integration', () => {
describe('POST /api/users', () => {
test('creates a user', async () => {
const userData = {
name: 'Alice',
email: '[email protected]',
password: 'password123'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(userData.name);
const user = await User.findById(response.body.id);
expect(user).toBeTruthy();
expect(user.email).toBe(userData.email);
});
});
describe('GET /api/users/:id', () => {
test('returns a user', async () => {
const user = await User.create({
name: 'Alice',
email: '[email protected]',
password: 'password123'
});
const response = await request(app)
.get(`/api/users/${user._id}`)
.expect(200);
expect(response.body.name).toBe(user.name);
expect(response.body.email).toBe(user.email);
});
test('404 when missing', async () => {
const response = await request(app)
.get('/api/users/507f1f77bcf86cd799439011')
.expect(404);
expect(response.body).toHaveProperty('error');
});
});
});
This is the concrete difference between an integration test and the earlier unit-style Supertest example: the earlier app.test.js tests the route handler’s logic in isolation (does it validate input, does it return the right status code), while this test goes further and verifies the side effect actually landed in the database — User.findById(response.body.id) after the POST confirms the created user genuinely persisted with the right data, not just that the HTTP response looked correct. A route that returns a plausible-looking 201 response without actually saving anything would pass a pure unit test of the handler but fail exactly this kind of integration check.
Auth integration
const request = require('supertest');
const app = require('../../app');
const User = require('../../models/User');
const bcrypt = require('bcrypt');
describe('Auth API', () => {
describe('POST /auth/login', () => {
test('logs in with valid credentials', async () => {
const password = 'password123';
await User.create({
name: 'Alice',
email: '[email protected]',
password: await bcrypt.hash(password, 10)
});
const response = await request(app)
.post('/auth/login')
.send({ email: '[email protected]', password })
.expect(200);
expect(response.body).toHaveProperty('token');
expect(response.body.user.email).toBe('[email protected]');
});
test('401 on wrong password', async () => {
await User.create({
name: 'Alice',
email: '[email protected]',
password: await bcrypt.hash('password123', 10)
});
await request(app)
.post('/auth/login')
.send({ email: '[email protected]', password: 'wrong' })
.expect(401);
});
});
describe('GET /api/profile', () => {
test('returns profile with token', async () => {
await User.create({
name: 'Alice',
email: '[email protected]',
password: await bcrypt.hash('password123', 10)
});
const loginResponse = await request(app)
.post('/auth/login')
.send({ email: '[email protected]', password: 'password123' });
const token = loginResponse.body.token;
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.user.email).toBe('[email protected]');
});
test('401 without token', async () => {
await request(app).get('/api/profile').expect(401);
});
});
});
Hashing the password with real bcrypt before creating the user — rather than storing it plaintext or mocking the hash — is deliberate: it means the login test is exercising the actual comparison logic the production auth middleware uses (bcrypt.compare), not a simplified stand-in for it, so a real bug in password verification would actually be caught here. The full login-then-profile flow (obtaining a real token from /auth/login, then using it in the Authorization header for /api/profile) is what makes this genuinely an integration test of the auth system, not just one endpoint — it verifies the token issued by one route is actually accepted by a completely different, independently-implemented route, which is exactly the kind of cross-module wiring a pure unit test can’t catch.
Mocha + Chai
npm install --save-dev mocha chai
{ "scripts": { "test": "mocha" } }
const { expect } = require('chai');
const { add, subtract } = require('../math');
describe('Math', () => {
describe('add()', () => {
it('adds two numbers', () => {
expect(add(2, 3)).to.equal(5);
});
});
describe('subtract()', () => {
it('subtracts two numbers', () => {
expect(subtract(5, 3)).to.equal(2);
});
});
});
The structural difference from Jest worth noticing: Mocha itself provides only the test runner (describe/it, hooks, reporting) and deliberately ships with no built-in assertion library, no built-in mocking, and no built-in coverage tool — which is exactly the “minimal core plus pluggable assertions” tradeoff the FAQ above mentions. Chai here is a separate, swappable choice for assertions specifically (Jest bakes its own expect in and doesn’t let you swap it), and a real Mocha project typically pairs it with Sinon for mocks/spies and a separate tool like nyc/Istanbul for coverage — more setup than Jest’s all-in-one approach, but more flexibility to pick each piece independently.
Chai
const { expect } = require('chai');
describe('Chai', () => {
it('equality', () => {
expect(2 + 2).to.equal(4);
expect({ name: 'Alice' }).to.deep.equal({ name: 'Alice' });
});
it('types', () => {
expect('hello').to.be.a('string');
expect(123).to.be.a('number');
expect([]).to.be.an('array');
});
});
Chai’s expect uses a chainable, English-sentence-like syntax (.to.be.a(...), .to.deep.equal(...)) instead of Jest’s separate-method-per-check style (.toBeCloseTo, .toHaveProperty) — functionally equivalent, but the chaining style means many of the words in a Chai assertion (.to, .be, .a) are purely for readability and don’t perform a check themselves; only the final method in the chain (.equal(...), .a('string')) actually asserts anything. .deep.equal is Chai’s version of Jest’s toEqual — structural rather than reference comparison — and forgetting the .deep. prefix is the Chai equivalent of the toBe/toEqual mixup covered earlier: expect(obj1).to.equal(obj2) on two structurally-identical-but-distinct objects fails for the same reference-vs-structural-equality reason.
Coverage
npm test -- --coverage
jest.config.js:
module.exports = {
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/index.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Coverage percentage (lines, branches, functions, statements) measures how much code executed during the test run, not whether that code’s behavior was actually verified correctly — a test that calls a function but asserts nothing about its result still counts as full coverage of that function, which is why coverage thresholds are useful as a floor that catches obviously-untested code paths, not a proxy for test quality itself. branches specifically is worth understanding as the more meaningful of the four metrics: it tracks whether every if/else/switch/ternary path was exercised, not just whether the containing function was called at all — a function can have 100% line coverage while its error-handling if branch never actually ran during any test, which branches coverage would catch and lines/functions coverage would miss entirely.
Practical examples
User service
// services/userService.js
const User = require('../models/User');
const bcrypt = require('bcrypt');
class UserService {
async createUser(userData) {
const { email, password, name } = userData;
const existing = await User.findOne({ email });
if (existing) {
throw new Error('Email already registered');
}
const hashedPassword = await bcrypt.hash(password, 10);
return User.create({ email, password: hashedPassword, name });
}
async getUserById(id) {
const user = await User.findById(id).select('-password');
if (!user) throw new Error('User not found');
return user;
}
async updateUser(id, updates) {
const user = await User.findByIdAndUpdate(id, updates, {
new: true,
runValidators: true
}).select('-password');
if (!user) throw new Error('User not found');
return user;
}
async deleteUser(id) {
const user = await User.findByIdAndDelete(id);
if (!user) throw new Error('User not found');
return user;
}
}
module.exports = new UserService();
const userService = require('./userService');
const User = require('../models/User');
describe('UserService', () => {
describe('createUser', () => {
test('creates user', async () => {
const userData = {
name: 'Alice',
email: '[email protected]',
password: 'password123'
};
const user = await userService.createUser(userData);
expect(user).toHaveProperty('_id');
expect(user.password).not.toBe(userData.password);
});
test('duplicate email', async () => {
await userService.createUser({
name: 'Alice',
email: '[email protected]',
password: 'password123'
});
await expect(userService.createUser({
name: 'Bob',
email: '[email protected]',
password: 'password456'
})).rejects.toThrow('Email already registered');
});
});
});
The expect(user.password).not.toBe(userData.password) assertion is doing real security-relevant work, not just incidental checking — it verifies the service is actually hashing the password before persisting it, rather than accidentally storing it in plaintext, which is exactly the kind of regression a test should catch immediately if someone later refactors createUser and drops the bcrypt.hash call by mistake. Testing UserService directly (rather than only through the HTTP layer) also means this test runs faster and points more precisely at “the service’s duplicate-check logic is broken” versus a failing integration test, which would only tell you “something in the whole POST /api/users flow is broken” without narrowing down where.
Auth API (extended)
See integration example above; add JWT verification checks that match your auth middleware.
Test doubles
Mock (email)
This section’s title header uses “mock” and “stub” as informal labels for the two examples below, but it’s worth being precise about the actual distinction the FAQ draws: a mock is used here because the test’s whole point is verifying the interaction — that sendMail got called with the correct to/subject/text — not just that some email-sending happened. Mocking nodemailer entirely (rather than letting a real transport attempt to connect to an SMTP server) is also what keeps this test fast and deterministic; hitting a real mail service in a test suite would make it slow, flaky (dependent on network and an external service’s uptime), and would risk actually sending test emails.
jest.mock('nodemailer');
describe('sendEmail', () => {
test('sends mail', async () => {
const mockSendMail = jest.fn().mockResolvedValue({ messageId: '123' });
nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail });
await sendEmail('[email protected]', 'Subject', 'Body');
expect(mockSendMail).toHaveBeenCalledWith({
to: '[email protected]',
subject: 'Subject',
text: 'Body'
});
});
});
Stub (payment)
Contrast this with the payment example: the test only checks the result returned (result.success, result.transactionId), never asserting anything about how axios.post was called — that’s the stub pattern from the FAQ’s definition, canned data standing in for a real payment gateway response, with no verification of the interaction itself. Neither approach is universally “better” — mocking is the right call when the interaction itself is the thing under test (did we call the mailer correctly), stubbing is enough when you only care about how the code under test reacts to a given input (does processPayment correctly surface a successful gateway response).
jest.mock('axios');
describe('processPayment', () => {
test('returns gateway data', async () => {
axios.post.mockResolvedValue({
data: { success: true, transactionId: 'txn_123' }
});
const result = await processPayment(10000);
expect(result.success).toBe(true);
expect(result.transactionId).toBe('txn_123');
});
});
Common issues
Async timeout
Timeout - Async callback was not invoked within the 5000 ms timeout
test('slow op', async () => {
await slowOperation();
}, 10000);
// jest.config.js
module.exports = { testTimeout: 10000 };
This timeout error almost always means one of two things, and it’s worth checking both before just raising the number: either the operation genuinely takes longer than Jest’s 5-second default (a legitimately slow external call, a large database seed), in which case a per-test or global timeout override like the ones shown is the right fix — or, more commonly, the test’s async function never actually resolves at all because of a bug (a promise that’s never awaited, a callback that’s never invoked, a mock that was never configured to resolve), in which case raising the timeout just delays the same failure by a few more seconds instead of fixing anything. Worth checking the second possibility first, since it’s the more common root cause in practice.
Shared state
Use beforeEach to reset fixtures; avoid module-level mutable arrays unless cleared. This is the same class of bug flagged earlier in the MSW guide’s e-commerce cart example — any mutable state living outside the scope of an individual test (a module-level array, a singleton’s internal cache, a class with static fields) persists across tests unless something explicitly resets it, and Jest running test files in isolation per-file doesn’t protect against leakage within the same file. The symptom is a distinctive kind of flakiness: a test passes in isolation but fails when run as part of the full suite, or passes/fails depending on test execution order — both are strong signals to go looking for exactly this pattern.
DB cleanup
afterEach(async () => {
await User.deleteMany({});
await Post.deleteMany({});
});
Deleting rather than dropping the whole database or reconnecting between tests is the pragmatic middle ground: a full drop-and-recreate is safer for isolation but noticeably slower when repeated before every single test, while deleteMany({}) on each collection clears the actual data fast without paying the cost of re-establishing the connection or re-running schema setup — a reasonable tradeoff as long as the collections being cleared genuinely cover everything a test could have written to.
Test layout, fixture helpers and TDD
Layout
test/
├── unit/
├── integration/
├── e2e/
└── setup.js
Helpers
async function createTestUser(overrides = {}) {
const defaults = {
name: 'Test User',
email: '[email protected]',
password: await bcrypt.hash('password123', 10),
role: 'user'
};
return User.create({ ...defaults, ...overrides });
}
A factory function like this earns its place once test fixture setup starts repeating across files — every integration test in this guide needed a user with name/email/password, and hardcoding that object literal in every single test both duplicates boilerplate and buries the field that actually matters for a given test under fields that don’t. createTestUser({ email: '[email protected]' }) makes the one field a test actually cares about visible at the call site, with everything else defaulted, which also means adding a new required field to the User schema later only requires updating the factory’s defaults once, not every individual test that creates a user.
TDD sketch
Write a failing test, minimal pass, refactor, add edge-case tests.
Related Articles
- Express.js: Node.js Web Framework and REST
- Node.js Authentication and Security: JWT, bcrypt, Sessions
- Node.js Performance
- Node.js Getting Started