Unit Testing Strategies for Node.js Applications
A beginner-friendly guide to unit testing Node.js applications with Jest, covering test structure, mocking, async testing, code coverage, testing Express handlers, and organizing tests at scale.
Unit Testing Strategies for Node.js Applications
Overview
Unit testing is how you prove your code works before it hits production -- and more importantly, how you prove it still works six months later when someone changes a dependency three layers deep. A solid unit test suite catches regressions in seconds, documents your code's expected behavior, and gives you the confidence to refactor without fear. This article covers everything from choosing a test runner to mocking database calls to organizing a test suite that scales with your codebase.
Prerequisites
- Node.js 18+ installed
- Basic familiarity with npm and
package.json - Working knowledge of JavaScript functions, Promises, and async/await
- A code editor (VS Code recommended for its built-in test integration)
What Makes a Good Unit Test
Before writing a single test, you need to understand what separates a useful test from a waste of time. The FIRST acronym captures it well:
- Fast -- Unit tests should run in milliseconds, not seconds. If your test suite takes minutes, developers stop running it. A test that hits a real database or makes HTTP calls is not a unit test.
- Isolated -- Each test runs independently. Test A should never affect Test B. No shared mutable state, no execution order dependencies.
- Repeatable -- Run the same test 1,000 times and get the same result. No randomness, no dependency on the current date (unless mocked), no reliance on external services.
- Self-Validating -- The test either passes or fails. No manual inspection of output. If a human has to read a log to determine success, it is not a test.
- Timely -- Write tests close to when you write the code. Tests written weeks later miss edge cases you have already forgotten.
If your test violates any of these, it is either an integration test (which has its own place) or a bad test.
Choosing a Test Runner
Node.js has three serious options for unit testing. Here is how they compare.
Jest
Jest is the default choice for most Node.js projects. It includes a test runner, assertion library, mocking framework, and code coverage tool -- all in one package.
npm install --save-dev jest
Pros: Zero config to start, built-in mocking, snapshot testing, parallel execution, great error messages. Cons: Heavier install size (~30MB), can be slower to start on large projects due to its transform pipeline.
Mocha + Chai
Mocha is a test runner. Chai is an assertion library. You assemble your own stack, which gives you more control but more setup.
npm install --save-dev mocha chai sinon
Pros: Lightweight, flexible, mature ecosystem, works well with any assertion library. Cons: Requires additional packages for mocking (sinon), coverage (nyc), and other features Jest includes by default.
Node.js Built-in Test Runner (node:test)
Since Node.js 18, there is a built-in test runner. No npm install required.
var test = require('node:test');
var assert = require('node:assert');
test('adds two numbers', function() {
assert.strictEqual(2 + 2, 4);
});
node --test test/*.js
Pros: Zero dependencies, fast startup, native describe/it support since Node 20.
Cons: Younger ecosystem, fewer community plugins, mocking is more manual.
My recommendation: Use Jest for most projects. The built-in mocking and coverage alone save you hours of setup. The rest of this article uses Jest, but the testing principles apply regardless of your runner.
Project Structure for Testable Code
The single most important thing you can do for testability is separate business logic from I/O. If your function reads from a database, makes an HTTP call, and then calculates a result, it is hard to test. If your function takes data as input and returns a result, it is trivial to test.
project/
src/
services/
userService.js # Business logic (testable)
emailService.js # Business logic (testable)
repositories/
userRepository.js # Database I/O (mocked in tests)
controllers/
userController.js # HTTP handling (thin layer)
utils/
validation.js # Pure functions (easiest to test)
test/
services/
userService.test.js
emailService.test.js
repositories/
userRepository.test.js
controllers/
userController.test.js
utils/
validation.test.js
fixtures/
users.js # Shared test data
The pattern: controllers are thin (they parse input and call services), services contain business logic (testable with mocked dependencies), and repositories handle I/O (tested separately or mocked out).
Writing Your First Tests with Jest
Setup
Add Jest to your project and configure the test script:
npm install --save-dev jest
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
By default, Jest finds files named *.test.js or *.spec.js, or any .js files inside a __tests__ directory.
The describe/it/expect Pattern
Every test file follows the same structure:
var { calculateDiscount } = require('../src/utils/pricing');
describe('calculateDiscount', function() {
it('should return 0 for orders under $50', function() {
var result = calculateDiscount(49.99);
expect(result).toBe(0);
});
it('should return 10% for orders between $50 and $100', function() {
var result = calculateDiscount(75);
expect(result).toBe(7.5);
});
it('should return 20% for orders over $100', function() {
var result = calculateDiscount(200);
expect(result).toBe(40);
});
});
describe groups related tests. it defines a single test case. expect makes assertions. This reads like a specification: "calculateDiscount should return 0 for orders under $50."
Testing Pure Functions
Pure functions are the easiest code to test -- same input always produces the same output, no side effects.
// src/utils/validation.js
function isValidEmail(email) {
if (typeof email !== 'string') return false;
var pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return pattern.test(email);
}
function sanitizeUsername(username) {
if (typeof username !== 'string') return '';
return username.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
}
function calculateAge(birthDate) {
var today = new Date();
var birth = new Date(birthDate);
var age = today.getFullYear() - birth.getFullYear();
var monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
}
module.exports = { isValidEmail, sanitizeUsername, calculateAge };
// test/utils/validation.test.js
var { isValidEmail, sanitizeUsername } = require('../../src/utils/validation');
describe('isValidEmail', function() {
it('should accept valid emails', function() {
expect(isValidEmail('[email protected]')).toBe(true);
expect(isValidEmail('[email protected]')).toBe(true);
});
it('should reject emails without @', function() {
expect(isValidEmail('userexample.com')).toBe(false);
});
it('should reject emails without domain', function() {
expect(isValidEmail('user@')).toBe(false);
});
it('should reject non-string inputs', function() {
expect(isValidEmail(null)).toBe(false);
expect(isValidEmail(undefined)).toBe(false);
expect(isValidEmail(42)).toBe(false);
});
});
describe('sanitizeUsername', function() {
it('should lowercase and trim', function() {
expect(sanitizeUsername(' Shane ')).toBe('shane');
});
it('should strip special characters', function() {
expect(sanitizeUsername('user@name!')).toBe('username');
});
it('should preserve hyphens and underscores', function() {
expect(sanitizeUsername('user-name_01')).toBe('user-name_01');
});
it('should return empty string for non-string input', function() {
expect(sanitizeUsername(null)).toBe('');
});
});
Run it:
npx jest test/utils/validation.test.js
PASS test/utils/validation.test.js
isValidEmail
✓ should accept valid emails (2 ms)
✓ should reject emails without @ (1 ms)
✓ should reject emails without domain
✓ should reject non-string inputs (1 ms)
sanitizeUsername
✓ should lowercase and trim
✓ should strip special characters
✓ should preserve hyphens and underscores (1 ms)
✓ should return empty string for non-string input
Test Suites: 1 passed, 1 total
Tests: 8 passed, 8 total
Time: 0.845 s
Testing Async Functions
Node.js is asynchronous by nature, so you will test async code constantly. Jest handles all three patterns: callbacks, Promises, and async/await.
Testing Promises
// src/services/fetchUser.js
var axios = require('axios');
function fetchUser(userId) {
return axios.get('https://api.example.com/users/' + userId)
.then(function(response) {
return response.data;
});
}
module.exports = { fetchUser };
// Return the promise from your test
it('should resolve with user data', function() {
return fetchUser(1).then(function(user) {
expect(user.name).toBeDefined();
});
});
Testing async/await
it('should resolve with user data', async function() {
var user = await fetchUser(1);
expect(user.name).toBeDefined();
});
Testing Callbacks
function readConfig(path, callback) {
var fs = require('fs');
fs.readFile(path, 'utf8', function(err, data) {
if (err) return callback(err);
try {
callback(null, JSON.parse(data));
} catch (parseErr) {
callback(parseErr);
}
});
}
it('should parse config file', function(done) {
readConfig('./test/fixtures/config.json', function(err, config) {
expect(err).toBeNull();
expect(config.port).toBe(3000);
done();
});
});
Always use done for callback-based tests. If you forget it, Jest will pass the test before the callback fires.
Mocking with jest.mock
Mocking is how you isolate the unit under test from its dependencies. When testing a service that calls a database, you mock the database module so your test never touches a real database.
Mocking an Entire Module
// src/services/userService.js
var userRepository = require('../repositories/userRepository');
var emailService = require('./emailService');
function createUser(userData) {
if (!userData.email || !userData.name) {
throw new Error('Name and email are required');
}
return userRepository.save(userData)
.then(function(savedUser) {
return emailService.sendWelcomeEmail(savedUser.email)
.then(function() {
return savedUser;
});
});
}
module.exports = { createUser };
// test/services/userService.test.js
var userRepository = require('../../src/repositories/userRepository');
var emailService = require('../../src/services/emailService');
var { createUser } = require('../../src/services/userService');
jest.mock('../../src/repositories/userRepository');
jest.mock('../../src/services/emailService');
describe('createUser', function() {
beforeEach(function() {
jest.clearAllMocks();
});
it('should save user and send welcome email', async function() {
var userData = { name: 'Shane', email: '[email protected]' };
var savedUser = { id: 1, name: 'Shane', email: '[email protected]' };
userRepository.save.mockResolvedValue(savedUser);
emailService.sendWelcomeEmail.mockResolvedValue(true);
var result = await createUser(userData);
expect(userRepository.save).toHaveBeenCalledWith(userData);
expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith('[email protected]');
expect(result).toEqual(savedUser);
});
it('should throw if email is missing', async function() {
expect(function() {
createUser({ name: 'Shane' });
}).toThrow('Name and email are required');
});
});
jest.mock() replaces every exported function with a jest.fn(). You then use .mockResolvedValue() for async functions or .mockReturnValue() for sync functions.
Mocking Specific Functions
Sometimes you only want to mock one function from a module:
jest.mock('../../src/repositories/userRepository', function() {
return {
save: jest.fn(),
findById: jest.fn(),
// Other methods keep their real implementations
validateSchema: require('../../src/repositories/userRepository').validateSchema
};
});
Mocking Timers
When your code uses setTimeout, setInterval, or Date.now, mock the clock:
function debounce(fn, delay) {
var timer;
return function() {
var args = arguments;
var context = this;
clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
};
}
// Test
describe('debounce', function() {
beforeEach(function() {
jest.useFakeTimers();
});
afterEach(function() {
jest.useRealTimers();
});
it('should only call function after delay', function() {
var fn = jest.fn();
var debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
});
Spying on Function Calls with jest.spyOn
jest.spyOn wraps a real function so you can track calls while still executing the original logic. This is different from jest.mock, which replaces the function entirely.
var mathUtils = require('../../src/utils/mathUtils');
describe('calculateTotal', function() {
it('should call applyTax internally', function() {
var spy = jest.spyOn(mathUtils, 'applyTax');
mathUtils.calculateTotal(100, 'CA');
expect(spy).toHaveBeenCalledWith(100, 0.0725);
spy.mockRestore(); // Always restore spies
});
});
You can also override the implementation temporarily:
var spy = jest.spyOn(console, 'error').mockImplementation(function() {});
// Run code that logs errors
expect(spy).toHaveBeenCalledWith('Connection failed');
spy.mockRestore();
Testing Error Cases
Testing the happy path is not enough. Your error handling code needs tests too.
Synchronous Errors
it('should throw on invalid input', function() {
expect(function() {
parseConfig('not valid json');
}).toThrow(SyntaxError);
});
it('should throw with specific message', function() {
expect(function() {
createUser({ name: '' });
}).toThrow('Name cannot be empty');
});
Async Errors
it('should reject when user is not found', async function() {
userRepository.findById.mockRejectedValue(new Error('User not found'));
await expect(getUserProfile(999)).rejects.toThrow('User not found');
});
it('should reject with specific error type', async function() {
userRepository.findById.mockRejectedValue(new NotFoundError('User 999'));
await expect(getUserProfile(999)).rejects.toBeInstanceOf(NotFoundError);
});
Note the await before expect -- this is required for async rejection assertions in Jest.
Test Fixtures and Setup/Teardown
beforeEach and afterEach
Run setup before each test and cleanup after each test:
describe('UserService', function() {
var mockDb;
beforeEach(function() {
mockDb = {
query: jest.fn(),
close: jest.fn()
};
jest.clearAllMocks();
});
afterEach(function() {
mockDb.close();
});
it('should query the database', async function() {
mockDb.query.mockResolvedValue([{ id: 1, name: 'Shane' }]);
var users = await mockDb.query('SELECT * FROM users');
expect(users).toHaveLength(1);
});
});
beforeAll and afterAll
Run expensive setup once for the entire describe block:
describe('Integration tests', function() {
var connection;
beforeAll(async function() {
connection = await createTestDatabase();
});
afterAll(async function() {
await connection.close();
});
// Tests use `connection`
});
Fixture Files
Create reusable test data:
// test/fixtures/users.js
var validUser = {
name: 'Shane',
email: '[email protected]',
role: 'admin'
};
var invalidUser = {
name: '',
email: 'not-an-email'
};
var userList = [
{ id: 1, name: 'Alice', email: '[email protected]', role: 'user' },
{ id: 2, name: 'Bob', email: '[email protected]', role: 'user' },
{ id: 3, name: 'Charlie', email: '[email protected]', role: 'admin' }
];
module.exports = { validUser, invalidUser, userList };
// In your test
var { validUser, userList } = require('../fixtures/users');
it('should create a valid user', async function() {
userRepository.save.mockResolvedValue({ id: 1, ...validUser });
var result = await createUser(validUser);
expect(result.id).toBe(1);
});
Code Coverage Configuration
Jest includes Istanbul for code coverage. Configure it in package.json or jest.config.js:
{
"jest": {
"collectCoverageFrom": [
"src/**/*.js",
"!src/index.js",
"!src/config/**"
],
"coverageThresholds": {
"global": {
"branches": 80,
"functions": 85,
"lines": 85,
"statements": 85
}
},
"coverageReporters": ["text", "lcov", "html"]
}
}
Run coverage:
npx jest --coverage
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 91.23 | 84.62 | 88.89 | 91.23 |
services/ | 89.47 | 81.82 | 87.50 | 89.47 |
userService.js | 93.33 | 85.71 | 100.00 | 93.33 |
emailService.js | 84.62 | 75.00 | 75.00 | 84.62 |
utils/ | 95.00 | 90.00 | 92.31 | 95.00 |
validation.js | 100.00 | 100.00 | 100.00 | 100.00 |
pricing.js | 90.00 | 80.00 | 83.33 | 90.00 |
--------------------|---------|----------|---------|---------|
Meaningful Thresholds
Do not chase 100% coverage. It leads to brittle tests that test implementation details rather than behavior. Here is what I recommend:
- Utility/helper functions: 95-100% (pure functions, easy to test thoroughly)
- Business logic/services: 85-90% (cover all branches, skip trivial getters)
- Controllers/routes: 70-80% (test the logic, not the framework)
- Overall project: 80-85% (a pragmatic target)
A function with 100% line coverage but 50% branch coverage still has untested paths. Branch coverage is the number that actually matters.
Testing Express Route Handlers in Isolation
You do not need to start a real HTTP server to test Express handlers. Mock the req and res objects.
// src/controllers/userController.js
var userService = require('../services/userService');
function getUser(req, res) {
var userId = req.params.id;
if (!userId || isNaN(userId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
return userService.findById(Number(userId))
.then(function(user) {
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
return res.json(user);
})
.catch(function(err) {
return res.status(500).json({ error: 'Internal server error' });
});
}
module.exports = { getUser };
// test/controllers/userController.test.js
var userService = require('../../src/services/userService');
var { getUser } = require('../../src/controllers/userController');
jest.mock('../../src/services/userService');
function mockResponse() {
var res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe('getUser controller', function() {
var req;
var res;
beforeEach(function() {
req = { params: {} };
res = mockResponse();
jest.clearAllMocks();
});
it('should return 400 for invalid user ID', async function() {
req.params.id = 'abc';
await getUser(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid user ID' });
});
it('should return 404 when user is not found', async function() {
req.params.id = '999';
userService.findById.mockResolvedValue(null);
await getUser(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
});
it('should return user data for valid request', async function() {
req.params.id = '1';
var userData = { id: 1, name: 'Shane', email: '[email protected]' };
userService.findById.mockResolvedValue(userData);
await getUser(req, res);
expect(res.json).toHaveBeenCalledWith(userData);
expect(res.status).not.toHaveBeenCalled();
});
it('should return 500 on service error', async function() {
req.params.id = '1';
userService.findById.mockRejectedValue(new Error('DB connection lost'));
await getUser(req, res);
expect(res.status).toHaveBeenCalledWith(500);
});
});
The mockResponse() helper is a pattern I use in every project. It creates a chainable mock (res.status(400).json(...) works because status returns res).
Testing Database Interactions
Never hit a real database in unit tests. Mock the database layer and test that your code calls it correctly.
// src/repositories/userRepository.js
var db = require('../db/connection');
function findById(id) {
return db.query('SELECT * FROM users WHERE id = $1', [id])
.then(function(result) {
return result.rows[0] || null;
});
}
function save(user) {
return db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[user.name, user.email]
).then(function(result) {
return result.rows[0];
});
}
module.exports = { findById, save };
// test/repositories/userRepository.test.js
var db = require('../../src/db/connection');
var { findById, save } = require('../../src/repositories/userRepository');
jest.mock('../../src/db/connection');
describe('userRepository', function() {
afterEach(function() {
jest.clearAllMocks();
});
describe('findById', function() {
it('should return user when found', async function() {
var mockUser = { id: 1, name: 'Shane', email: '[email protected]' };
db.query.mockResolvedValue({ rows: [mockUser] });
var result = await findById(1);
expect(db.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = $1',
[1]
);
expect(result).toEqual(mockUser);
});
it('should return null when user is not found', async function() {
db.query.mockResolvedValue({ rows: [] });
var result = await findById(999);
expect(result).toBeNull();
});
});
describe('save', function() {
it('should insert and return the new user', async function() {
var newUser = { name: 'Alice', email: '[email protected]' };
var savedUser = { id: 5, name: 'Alice', email: '[email protected]' };
db.query.mockResolvedValue({ rows: [savedUser] });
var result = await save(newUser);
expect(db.query).toHaveBeenCalledWith(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
['Alice', '[email protected]']
);
expect(result.id).toBe(5);
});
});
});
Snapshot Testing
Snapshot testing captures the output of a function and compares it against a stored reference. It is useful for testing serialized output -- JSON responses, rendered HTML, configuration objects.
it('should return the expected API response shape', async function() {
var response = await formatUserResponse(mockUser);
expect(response).toMatchSnapshot();
});
The first run creates a .snap file. Subsequent runs compare against it. If the output changes, the test fails.
When to use snapshot testing:
- API response shapes (catching unintended structural changes)
- Configuration object generation
- Error message formatting
When NOT to use snapshot testing:
- Anything with timestamps or random values (tests will always fail)
- Large objects (snapshot diffs become unreadable)
- As a substitute for specific assertions (lazy testing)
Update snapshots when changes are intentional:
npx jest --updateSnapshot
Parameterized Tests with test.each
When you have the same test logic with different inputs, use test.each instead of duplicating test cases:
describe('isValidEmail', function() {
test.each([
['[email protected]', true],
['[email protected]', true],
['[email protected]', true],
['', false],
['not-an-email', false],
['user@', false],
['@domain.com', false],
[null, false],
[undefined, false],
[42, false]
])('isValidEmail(%s) should return %s', function(input, expected) {
expect(isValidEmail(input)).toBe(expected);
});
});
Output:
isValidEmail
✓ isValidEmail([email protected]) should return true
✓ isValidEmail([email protected]) should return true
✓ isValidEmail([email protected]) should return true
✓ isValidEmail() should return false
✓ isValidEmail(not-an-email) should return false
✓ isValidEmail(user@) should return false
✓ isValidEmail(@domain.com) should return false
✓ isValidEmail(null) should return false
✓ isValidEmail(undefined) should return false
✓ isValidEmail(42) should return false
You can also use objects for more readable parameters:
test.each([
{ amount: 49.99, expected: 0, label: 'no discount under $50' },
{ amount: 75.00, expected: 7.50, label: '10% between $50-$100' },
{ amount: 200.00, expected: 40.00, label: '20% over $100' }
])('calculateDiscount: $label', function(testCase) {
expect(calculateDiscount(testCase.amount)).toBe(testCase.expected);
});
Organizing Tests at Scale
When your project grows past 50 test files, organization matters.
Naming Conventions
- Test files mirror source files:
src/services/userService.js-->test/services/userService.test.js - Describe blocks match module/function names
- Test descriptions start with "should" and describe behavior, not implementation
Shared Utilities
// test/helpers/mockFactory.js
function createMockRequest(overrides) {
return Object.assign({
params: {},
query: {},
body: {},
headers: { 'content-type': 'application/json' },
get: jest.fn()
}, overrides);
}
function createMockResponse() {
var res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
return res;
}
module.exports = { createMockRequest, createMockResponse };
Jest Configuration for Large Projects
// jest.config.js
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/index.js',
'!src/config/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 85,
lines: 85,
statements: 85
}
},
setupFilesAfterSetup: ['<rootDir>/test/setup.js'],
maxWorkers: '50%' // Use half of available CPU cores
};
// test/setup.js
// Global test setup -- runs once before all tests
jest.setTimeout(10000); // 10-second timeout for slow CI environments
// Suppress console.log in tests unless debugging
if (!process.env.DEBUG_TESTS) {
global.console = {
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn()
};
}
Complete Working Example
Here is a realistic OrderService with validation, database calls, and external API calls -- plus a complete test suite.
The Module
// src/services/orderService.js
var orderRepository = require('../repositories/orderRepository');
var inventoryService = require('./inventoryService');
var paymentGateway = require('../integrations/paymentGateway');
function validateOrder(order) {
var errors = [];
if (!order.items || order.items.length === 0) {
errors.push('Order must contain at least one item');
}
if (!order.customerId) {
errors.push('Customer ID is required');
}
if (order.items) {
order.items.forEach(function(item, index) {
if (!item.productId) {
errors.push('Item ' + index + ' missing productId');
}
if (!item.quantity || item.quantity < 1) {
errors.push('Item ' + index + ' must have quantity >= 1');
}
});
}
return errors;
}
function calculateTotal(items) {
return items.reduce(function(sum, item) {
return sum + (item.price * item.quantity);
}, 0);
}
function createOrder(orderData) {
var errors = validateOrder(orderData);
if (errors.length > 0) {
return Promise.reject(new Error('Validation failed: ' + errors.join(', ')));
}
return inventoryService.checkAvailability(orderData.items)
.then(function(availability) {
var unavailable = availability.filter(function(a) { return !a.available; });
if (unavailable.length > 0) {
var names = unavailable.map(function(a) { return a.productId; }).join(', ');
throw new Error('Items out of stock: ' + names);
}
var total = calculateTotal(orderData.items);
return paymentGateway.charge({
customerId: orderData.customerId,
amount: total,
currency: 'USD'
});
})
.then(function(paymentResult) {
if (!paymentResult.success) {
throw new Error('Payment failed: ' + paymentResult.reason);
}
var order = {
customerId: orderData.customerId,
items: orderData.items,
total: calculateTotal(orderData.items),
paymentId: paymentResult.transactionId,
status: 'confirmed',
createdAt: new Date().toISOString()
};
return orderRepository.save(order);
});
}
function getOrdersByCustomer(customerId) {
if (!customerId) {
return Promise.reject(new Error('Customer ID is required'));
}
return orderRepository.findByCustomerId(customerId);
}
module.exports = { createOrder, getOrdersByCustomer, validateOrder, calculateTotal };
The Test Suite
// test/services/orderService.test.js
var orderRepository = require('../../src/repositories/orderRepository');
var inventoryService = require('../../src/services/inventoryService');
var paymentGateway = require('../../src/integrations/paymentGateway');
var {
createOrder,
getOrdersByCustomer,
validateOrder,
calculateTotal
} = require('../../src/services/orderService');
jest.mock('../../src/repositories/orderRepository');
jest.mock('../../src/services/inventoryService');
jest.mock('../../src/integrations/paymentGateway');
describe('OrderService', function() {
beforeEach(function() {
jest.clearAllMocks();
});
// --- Pure function tests ---
describe('validateOrder', function() {
it('should return no errors for a valid order', function() {
var order = {
customerId: 'cust-1',
items: [{ productId: 'prod-1', quantity: 2, price: 29.99 }]
};
expect(validateOrder(order)).toEqual([]);
});
it('should require at least one item', function() {
var order = { customerId: 'cust-1', items: [] };
var errors = validateOrder(order);
expect(errors).toContain('Order must contain at least one item');
});
it('should require customerId', function() {
var order = { items: [{ productId: 'p1', quantity: 1 }] };
var errors = validateOrder(order);
expect(errors).toContain('Customer ID is required');
});
test.each([
{ quantity: 0, msg: 'must have quantity >= 1' },
{ quantity: -1, msg: 'must have quantity >= 1' },
{ quantity: undefined, msg: 'must have quantity >= 1' }
])('should reject item with quantity $quantity', function(testCase) {
var order = {
customerId: 'cust-1',
items: [{ productId: 'p1', quantity: testCase.quantity }]
};
var errors = validateOrder(order);
expect(errors.some(function(e) { return e.indexOf(testCase.msg) !== -1; })).toBe(true);
});
});
describe('calculateTotal', function() {
it('should sum price * quantity for all items', function() {
var items = [
{ price: 10.00, quantity: 2 },
{ price: 5.50, quantity: 3 }
];
expect(calculateTotal(items)).toBe(36.50);
});
it('should return 0 for empty items', function() {
expect(calculateTotal([])).toBe(0);
});
});
// --- Async tests with mocking ---
describe('createOrder', function() {
var validOrder = {
customerId: 'cust-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 25.00 },
{ productId: 'prod-2', quantity: 1, price: 15.00 }
]
};
it('should reject with validation errors for invalid order', async function() {
await expect(createOrder({ items: [] }))
.rejects.toThrow('Validation failed');
});
it('should check inventory before charging', async function() {
inventoryService.checkAvailability.mockResolvedValue([
{ productId: 'prod-1', available: true },
{ productId: 'prod-2', available: true }
]);
paymentGateway.charge.mockResolvedValue({
success: true,
transactionId: 'txn-456'
});
orderRepository.save.mockResolvedValue({ id: 'order-789' });
await createOrder(validOrder);
expect(inventoryService.checkAvailability).toHaveBeenCalledWith(validOrder.items);
expect(inventoryService.checkAvailability).toHaveBeenCalledBefore(
paymentGateway.charge
);
});
it('should throw when items are out of stock', async function() {
inventoryService.checkAvailability.mockResolvedValue([
{ productId: 'prod-1', available: true },
{ productId: 'prod-2', available: false }
]);
await expect(createOrder(validOrder))
.rejects.toThrow('Items out of stock: prod-2');
expect(paymentGateway.charge).not.toHaveBeenCalled();
});
it('should throw when payment fails', async function() {
inventoryService.checkAvailability.mockResolvedValue([
{ productId: 'prod-1', available: true },
{ productId: 'prod-2', available: true }
]);
paymentGateway.charge.mockResolvedValue({
success: false,
reason: 'Insufficient funds'
});
await expect(createOrder(validOrder))
.rejects.toThrow('Payment failed: Insufficient funds');
expect(orderRepository.save).not.toHaveBeenCalled();
});
it('should save confirmed order after successful payment', async function() {
inventoryService.checkAvailability.mockResolvedValue([
{ productId: 'prod-1', available: true },
{ productId: 'prod-2', available: true }
]);
paymentGateway.charge.mockResolvedValue({
success: true,
transactionId: 'txn-456'
});
orderRepository.save.mockImplementation(function(order) {
return Promise.resolve(Object.assign({ id: 'order-789' }, order));
});
var result = await createOrder(validOrder);
expect(orderRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
customerId: 'cust-123',
total: 65.00,
paymentId: 'txn-456',
status: 'confirmed'
})
);
expect(result.id).toBe('order-789');
});
it('should calculate correct total', async function() {
inventoryService.checkAvailability.mockResolvedValue([
{ productId: 'prod-1', available: true },
{ productId: 'prod-2', available: true }
]);
paymentGateway.charge.mockResolvedValue({
success: true,
transactionId: 'txn-456'
});
orderRepository.save.mockImplementation(function(order) {
return Promise.resolve(order);
});
await createOrder(validOrder);
// 2 * $25.00 + 1 * $15.00 = $65.00
expect(paymentGateway.charge).toHaveBeenCalledWith(
expect.objectContaining({ amount: 65.00 })
);
});
});
// --- Testing error propagation ---
describe('getOrdersByCustomer', function() {
it('should reject when customerId is missing', async function() {
await expect(getOrdersByCustomer(null))
.rejects.toThrow('Customer ID is required');
});
it('should return orders from repository', async function() {
var mockOrders = [
{ id: 'order-1', total: 50.00 },
{ id: 'order-2', total: 75.00 }
];
orderRepository.findByCustomerId.mockResolvedValue(mockOrders);
var result = await getOrdersByCustomer('cust-123');
expect(result).toEqual(mockOrders);
expect(orderRepository.findByCustomerId).toHaveBeenCalledWith('cust-123');
});
it('should propagate repository errors', async function() {
orderRepository.findByCustomerId.mockRejectedValue(
new Error('Connection timeout')
);
await expect(getOrdersByCustomer('cust-123'))
.rejects.toThrow('Connection timeout');
});
});
});
Run the suite:
npx jest test/services/orderService.test.js --verbose --coverage
PASS test/services/orderService.test.js
OrderService
validateOrder
✓ should return no errors for a valid order (2 ms)
✓ should require at least one item (1 ms)
✓ should require customerId
✓ should reject item with quantity 0 (1 ms)
✓ should reject item with quantity -1
✓ should reject item with quantity undefined (1 ms)
calculateTotal
✓ should sum price * quantity for all items
✓ should return 0 for empty items
createOrder
✓ should reject with validation errors for invalid order (1 ms)
✓ should check inventory before charging (1 ms)
✓ should throw when items are out of stock
✓ should throw when payment fails (1 ms)
✓ should save confirmed order after successful payment (1 ms)
✓ should calculate correct total
getOrdersByCustomer
✓ should reject when customerId is missing
✓ should return orders from repository (1 ms)
✓ should propagate repository errors
---------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
---------------------|---------|----------|---------|---------|
All files | 97.30 | 93.33 | 100.00 | 97.30 |
orderService.js | 97.30 | 93.33 | 100.00 | 97.30 |
---------------------|---------|----------|---------|---------|
Test Suites: 1 passed, 1 total
Tests: 17 passed, 17 total
Snapshots: 0 total
Time: 1.234 s
Common Issues and Troubleshooting
1. "Cannot find module" When Mocking
FAIL test/services/userService.test.js
● Test suite failed to run
Cannot find module '../../src/repositories/userRepository' from 'test/services/userService.test.js'
Cause: The path in jest.mock() must match exactly -- relative to the test file, not the source file. Also, check that the file actually exists at that path.
Fix: Double-check your relative paths. Use path.resolve if paths are complex, or configure moduleNameMapper in jest.config.js for path aliases.
// jest.config.js
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
}
};
2. "Async callback was not invoked within the 5000ms timeout"
FAIL test/services/emailService.test.js
● EmailService › sendEmail › should retry on failure
Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.
Cause: Your test uses done but never calls it, or an async function rejects but you have no .catch or try/catch. Also common when using fake timers but forgetting to advance them.
Fix:
// If using async/await, do NOT use done
it('should work', async function() {
// No done parameter -- Jest waits for the promise
var result = await someAsyncThing();
expect(result).toBeDefined();
});
// If using fake timers, advance them
jest.advanceTimersByTime(5000);
3. "ReferenceError: jest is not defined" in Setup Files
ReferenceError: jest is not defined
at Object.<anonymous> (test/setup.js:1:1)
Cause: You used setupFiles instead of setupFilesAfterSetup (or setupFilesAfterFramework in newer Jest versions). The setupFiles key runs before Jest globals are injected.
Fix: Use setupFilesAfterSetup in your Jest config:
{
"jest": {
"setupFilesAfterSetup": ["./test/setup.js"]
}
}
4. Mock Not Resetting Between Tests
FAIL test/services/orderService.test.js
● OrderService › createOrder › should check inventory
expect(jest.fn()).toHaveBeenCalledTimes(1)
Expected number of calls: 1
Received number of calls: 3
Cause: Mocks accumulate calls across tests when you forget to clear them.
Fix: Always clear mocks in beforeEach:
beforeEach(function() {
jest.clearAllMocks(); // Clears call counts and return values
// OR
jest.resetAllMocks(); // Also resets implementations
// OR
jest.restoreAllMocks(); // Also restores spied-on functions
});
The differences matter. clearAllMocks resets call counts. resetAllMocks also removes mock implementations. restoreAllMocks also undoes jest.spyOn. For most test suites, clearAllMocks is sufficient.
5. "TypeError: Cannot read property 'mockResolvedValue' of undefined"
TypeError: Cannot read properties of undefined (reading 'mockResolvedValue')
at Object.<anonymous> (test/services/userService.test.js:18:29)
Cause: The jest.mock() call did not find the function you are trying to mock. Usually the module exports an object or class and you are treating it as a direct function.
Fix: Check how the module is exported:
// If the module exports: module.exports = { save, findById }
// Then jest.mock auto-mocks each property
userRepository.save.mockResolvedValue(data); // works
// If the module exports: module.exports = function() { ... }
// You need a factory function
jest.mock('../../src/lib/logger', function() {
return jest.fn().mockImplementation(function() {
return { info: jest.fn(), error: jest.fn() };
});
});
Best Practices
Test behavior, not implementation. Assert what a function returns or what side effects it produces. Do not assert the internal sequence of operations. When you refactor internals, your tests should still pass.
One assertion per concept. A test can have multiple
expectcalls, but they should all verify one logical behavior. If a test name needs the word "and", it is probably two tests.Name tests as specifications. "should return 404 when user is not found" is good. "test getUserById" is bad. Good test names serve as documentation for your codebase.
Clean up after yourself. Use
afterEachto clear mocks, close connections, and reset state. Leaked state causes flaky tests that pass alone and fail in CI -- the worst kind of bug.Use
jest.clearAllMocks()inbeforeEach, not in individual tests. This ensures every test starts from a clean slate even if someone adds a test in the middle of the suite.Do not mock what you do not own -- wrap it first. Instead of mocking
axios.getdirectly throughout your tests, create a thin wrapper (apiClient.js) and mock that. When axios releases a breaking change, you update one wrapper, not 50 test files.Run tests in CI on every push. A test suite that only runs locally is a test suite that people skip. Put
npm testin your CI pipeline and block merges on failure.Keep test data close to tests. Fixture files in a
test/fixtures/directory are fine. A shared database seed that 40 test files depend on is a maintenance nightmare.Prefer
toEqualovertoBefor objects.toBeusesObject.is(reference equality).toEqualdoes deep comparison. You almost always want deep comparison when testing objects and arrays.Do not ignore flaky tests -- fix them. A test that passes 9 out of 10 times is worse than no test at all. It trains your team to ignore test failures. Flaky tests are usually caused by shared state, race conditions, or reliance on timing.
References
- Jest Documentation -- Official docs, well-written and comprehensive
- Node.js Built-in Test Runner -- API reference for
node:test - Sinon.js -- Standalone test spies, stubs, and mocks (for Mocha users)
- Istanbul / nyc -- Code coverage tool (used by Jest under the hood)
- Testing JavaScript by Kent C. Dodds -- Comprehensive testing course
- Jest Mock Functions -- Deep dive on jest.fn(), jest.mock(), jest.spyOn()
- Martin Fowler - Unit Test -- Foundational article on what constitutes a unit test
