Episode 3 — NodeJS MongoDB Backend Architecture / 3.18 — Testing Tools
3.18.b — Unit Testing with Jest
Jest is a zero-configuration, batteries-included testing framework by Meta that provides a test runner, assertion library, mocking utilities, and code coverage -- all in a single package, making it the most popular testing tool in the Node.js ecosystem.
<< Previous: 3.18.a — Introduction to Testing | Next: 3.18.c — API Testing >>
1. What is Jest?
Jest is an open-source JavaScript testing framework created and maintained by Meta (formerly Facebook). It was originally designed for testing React applications, but it works equally well for testing any JavaScript code -- including Node.js backends, utility libraries, and Express APIs.
Why Jest?
| Feature | Description |
|---|---|
| Zero configuration | Works out of the box for most projects. No plugins needed. |
| Built-in assertions | expect() API with 50+ matchers -- no separate library required |
| Built-in mocking | jest.fn(), jest.mock(), jest.spyOn() -- no Sinon needed |
| Built-in coverage | Run --coverage flag for a full code coverage report |
| Watch mode | Automatically re-runs tests when files change |
| Snapshot testing | Capture output and detect unintended changes |
| Parallel execution | Runs test files in parallel worker processes |
| Rich ecosystem | Huge community, extensive documentation, many plugins |
2. Installation and Setup
Installing Jest
# Install Jest as a development dependency
npm install -D jest
Configuring package.json
Add a test script to your package.json:
{
"name": "my-app",
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
jest.config.js Options
For more control, create a jest.config.js in your project root:
module.exports = {
// Where to look for test files
testMatch: [
'**/__tests__/**/*.js',
'**/?(*.)+(spec|test).js'
],
// The test environment (node for backend, jsdom for frontend)
testEnvironment: 'node',
// Where to output coverage reports
coverageDirectory: 'coverage',
// Which files to collect coverage from
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/**/index.js'
],
// Coverage thresholds (fail if below these)
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// Setup files to run before tests
setupFilesAfterSetup: ['./tests/setup.js'],
// Timeout for each test (default: 5000ms)
testTimeout: 10000,
// Display individual test results
verbose: true
};
3. Test File Naming and Organization
Jest automatically finds test files using these conventions:
| Convention | Example | Description |
|---|---|---|
*.test.js | userService.test.js | Test file next to source file |
*.spec.js | userService.spec.js | Alternate naming convention |
__tests__/ folder | __tests__/userService.js | Tests in a dedicated folder |
Common Project Structures
# Option 1: Test files alongside source files (recommended)
src/
services/
userService.js
userService.test.js
utils/
helpers.js
helpers.test.js
# Option 2: Mirror structure in a tests folder
src/
services/
userService.js
utils/
helpers.js
tests/
services/
userService.test.js
utils/
helpers.test.js
# Option 3: __tests__ folders inside each directory
src/
services/
__tests__/
userService.test.js
userService.js
4. Writing Your First Tests
Basic Test Structure
// math.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// math.test.js
const { add, multiply } = require('./math');
// describe() groups related tests
describe('Math Functions', () => {
// it() or test() defines a single test case
it('should add two numbers correctly', () => {
expect(add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
test('should multiply two numbers', () => {
expect(multiply(3, 4)).toBe(12);
});
test('should return 0 when multiplying by 0', () => {
expect(multiply(5, 0)).toBe(0);
});
});
Running Tests
# Run all tests
npm test
# Run tests in watch mode (re-runs on file changes)
npm run test:watch
# Run a specific test file
npx jest math.test.js
# Run tests matching a pattern
npx jest --testPathPattern="service"
# Run tests with verbose output
npx jest --verbose
5. Assertions (Matchers)
Jest provides a rich set of matchers through the expect() API. These are the tools you use to verify your code's behavior.
Equality Matchers
// toBe — strict equality (===), use for primitives
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');
expect(true).toBe(true);
// toEqual — deep equality, use for objects and arrays
expect({ name: 'Alice' }).toEqual({ name: 'Alice' });
expect([1, 2, 3]).toEqual([1, 2, 3]);
// toStrictEqual — like toEqual but also checks:
// - undefined properties
// - array sparseness
// - object types (class instances)
expect({ name: 'Alice', age: undefined }).not.toStrictEqual({ name: 'Alice' });
Truthiness Matchers
expect(true).toBeTruthy(); // truthy value (not false, 0, '', null, undefined, NaN)
expect(false).toBeFalsy(); // falsy value
expect(null).toBeNull(); // === null
expect(undefined).toBeUndefined(); // === undefined
expect('hello').toBeDefined(); // !== undefined
Number Matchers
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(5).toBeLessThanOrEqual(5);
// For floating point — avoid toBe due to precision issues
expect(0.1 + 0.2).toBeCloseTo(0.3); // Passes (accounts for floating-point errors)
expect(0.1 + 0.2).not.toBe(0.3); // Would fail! (0.30000000000000004)
String Matchers
// toMatch — regex or substring
expect('Hello, World!').toMatch(/World/);
expect('Hello, World!').toMatch('World');
expect('user@email.com').toMatch(/^[\w.-]+@[\w.-]+\.\w+$/);
Array and Iterable Matchers
const fruits = ['apple', 'banana', 'cherry'];
expect(fruits).toContain('banana'); // Array includes item (===)
expect(fruits).toHaveLength(3); // Array length
const users = [{ name: 'Alice' }, { name: 'Bob' }];
expect(users).toContainEqual({ name: 'Alice' }); // Deep equality check in array
Object Matchers
const user = { name: 'Alice', age: 30, email: 'alice@test.com' };
expect(user).toHaveProperty('name'); // Property exists
expect(user).toHaveProperty('name', 'Alice'); // Property has specific value
expect(user).toHaveProperty('age', expect.any(Number)); // Property is any number
// toMatchObject — partial match (object contains at least these properties)
expect(user).toMatchObject({ name: 'Alice', age: 30 });
// Passes even though user also has 'email'
Exception Matchers
function throwError() {
throw new Error('Something went wrong');
}
function throwCustom() {
throw new TypeError('Invalid type');
}
// Must wrap the call in a function
expect(() => throwError()).toThrow();
expect(() => throwError()).toThrow('Something went wrong');
expect(() => throwError()).toThrow(/wrong/);
expect(() => throwCustom()).toThrow(TypeError);
The .not Modifier
Any matcher can be negated with .not:
expect(5).not.toBe(10);
expect({ a: 1 }).not.toEqual({ a: 2 });
expect([1, 2, 3]).not.toContain(4);
expect('hello').not.toMatch(/goodbye/);
Quick Reference Table
| Category | Matcher | Purpose |
|---|---|---|
| Equality | toBe(value) | Strict equality (===) |
| Equality | toEqual(value) | Deep equality |
| Equality | toStrictEqual(value) | Strict deep equality |
| Truthiness | toBeTruthy() | Truthy value |
| Truthiness | toBeFalsy() | Falsy value |
| Truthiness | toBeNull() | Is null |
| Truthiness | toBeUndefined() | Is undefined |
| Truthiness | toBeDefined() | Is not undefined |
| Numbers | toBeGreaterThan(n) | > |
| Numbers | toBeLessThan(n) | < |
| Numbers | toBeCloseTo(n) | Floating-point safe === |
| Strings | toMatch(regex/string) | Regex or substring match |
| Arrays | toContain(item) | Array includes (===) |
| Arrays | toContainEqual(item) | Array includes (deep equal) |
| Arrays | toHaveLength(n) | Array/string length |
| Objects | toHaveProperty(key, val?) | Object property check |
| Objects | toMatchObject(subset) | Partial object match |
| Exceptions | toThrow(error?) | Function throws |
6. Setup and Teardown
When multiple tests share setup logic, use lifecycle hooks to avoid repetition.
describe('UserService', () => {
let db;
let userService;
// Runs ONCE before all tests in this describe block
beforeAll(async () => {
db = await connectToTestDatabase();
});
// Runs ONCE after all tests in this describe block
afterAll(async () => {
await db.close();
});
// Runs before EACH test
beforeEach(async () => {
userService = new UserService(db);
await db.collection('users').insertMany([
{ name: 'Alice', email: 'alice@test.com' },
{ name: 'Bob', email: 'bob@test.com' }
]);
});
// Runs after EACH test
afterEach(async () => {
await db.collection('users').deleteMany({});
});
test('finds all users', async () => {
const users = await userService.findAll();
expect(users).toHaveLength(2);
});
test('finds user by email', async () => {
const user = await userService.findByEmail('alice@test.com');
expect(user.name).toBe('Alice');
});
});
Execution Order
beforeAll()
beforeEach()
test 1
afterEach()
beforeEach()
test 2
afterEach()
afterAll()
Nested Describe Blocks
describe('UserService', () => {
beforeEach(() => { /* runs before every test */ });
describe('findAll', () => {
beforeEach(() => { /* runs before findAll tests only */ });
test('returns all users', () => { /* ... */ });
test('returns empty array when no users', () => { /* ... */ });
});
describe('create', () => {
beforeEach(() => { /* runs before create tests only */ });
test('creates a user with valid data', () => { /* ... */ });
test('throws on duplicate email', () => { /* ... */ });
});
});
7. Testing Async Code
Most Node.js code is asynchronous. Jest supports multiple patterns for testing async functions.
async/await (Recommended)
test('fetches user from database', async () => {
const user = await userService.findById('123');
expect(user.name).toBe('Alice');
});
test('throws when user not found', async () => {
await expect(userService.findById('nonexistent'))
.rejects.toThrow('User not found');
});
.resolves and .rejects
test('resolves with user data', () => {
return expect(userService.findById('123'))
.resolves.toEqual({ name: 'Alice', email: 'alice@test.com' });
});
test('rejects when user not found', () => {
return expect(userService.findById('nonexistent'))
.rejects.toThrow('User not found');
});
Callback Style (Legacy)
test('reads file content', (done) => {
fs.readFile('/path/to/file', 'utf8', (err, data) => {
expect(err).toBeNull();
expect(data).toContain('expected content');
done(); // Signal that the async test is complete
});
});
8. Mocking
Mocking is one of Jest's most powerful features. It allows you to replace real dependencies with controlled fakes, isolating the code under test.
jest.fn() -- Mock Functions
Create a standalone mock function with call tracking:
const mockCallback = jest.fn();
// Call the mock
mockCallback('hello');
mockCallback('world');
// Verify calls
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith('hello');
expect(mockCallback).toHaveBeenLastCalledWith('world');
// Access call details
expect(mockCallback.mock.calls).toEqual([['hello'], ['world']]);
Mock Return Values
const mockFn = jest.fn();
// Return a specific value
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
// Return different values on successive calls
mockFn
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(99);
expect(mockFn()).toBe(1); // First call
expect(mockFn()).toBe(2); // Second call
expect(mockFn()).toBe(99); // All subsequent calls
// Mock resolved/rejected values for async functions
const asyncMock = jest.fn();
asyncMock.mockResolvedValue({ id: 1, name: 'Alice' });
const result = await asyncMock();
expect(result).toEqual({ id: 1, name: 'Alice' });
asyncMock.mockRejectedValue(new Error('DB Error'));
await expect(asyncMock()).rejects.toThrow('DB Error');
jest.mock() -- Auto-Mocking Modules
Replace an entire module with a mock:
// userService.js
const db = require('./database');
async function getUser(id) {
const user = await db.findById('users', id);
if (!user) throw new Error('User not found');
return user;
}
module.exports = { getUser };
// userService.test.js
const db = require('./database');
const { getUser } = require('./userService');
// Auto-mock the entire database module
jest.mock('./database');
describe('getUser', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('returns user when found', async () => {
// Configure the mock's return value
db.findById.mockResolvedValue({ id: '123', name: 'Alice' });
const user = await getUser('123');
expect(user).toEqual({ id: '123', name: 'Alice' });
expect(db.findById).toHaveBeenCalledWith('users', '123');
});
test('throws when user not found', async () => {
db.findById.mockResolvedValue(null);
await expect(getUser('999')).rejects.toThrow('User not found');
});
});
jest.mock() with Custom Implementation
jest.mock('./emailService', () => ({
sendWelcomeEmail: jest.fn().mockResolvedValue({ sent: true }),
sendResetEmail: jest.fn().mockResolvedValue({ sent: true })
}));
jest.spyOn() -- Spy on Existing Methods
Spy on a method without replacing it (or replace it temporarily):
const userService = require('./userService');
test('logs when user is created', () => {
// Spy on console.log without changing its behavior
const logSpy = jest.spyOn(console, 'log');
userService.create({ name: 'Alice' });
expect(logSpy).toHaveBeenCalledWith('User created: Alice');
// Restore the original implementation
logSpy.mockRestore();
});
test('spy and override return value', () => {
const spy = jest.spyOn(userService, 'findAll');
spy.mockReturnValue([{ name: 'Mock User' }]);
const users = userService.findAll();
expect(users).toEqual([{ name: 'Mock User' }]);
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // Restore original
});
Clearing, Resetting, and Restoring Mocks
| Method | What It Does | When to Use |
|---|---|---|
mockFn.mockClear() | Clears call history and instances | Between tests -- reset tracking only |
mockFn.mockReset() | Clears + removes return values and implementations | Between tests -- full reset |
mockFn.mockRestore() | Restores original implementation (spyOn only) | After spy tests -- undo the spy |
jest.clearAllMocks() | Calls mockClear on all mocks | In afterEach -- common pattern |
jest.resetAllMocks() | Calls mockReset on all mocks | In afterEach -- stricter cleanup |
jest.restoreAllMocks() | Calls mockRestore on all mocks | In afterEach -- restore all spies |
afterEach(() => {
jest.clearAllMocks(); // Most common cleanup pattern
});
9. Test Coverage
Code coverage measures how much of your source code is executed during tests.
Generating a Coverage Report
npx jest --coverage
Reading the Report
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 87.5 | 75.0 | 90.0 | 88.2 |
userService.js | 92.3 | 83.3 | 100.0 | 92.3 |
helpers.js | 80.0 | 60.0 | 75.0 | 81.8 |
validators.js | 100.0 | 100.0 | 100.0 | 100.0 |
--------------------|---------|----------|---------|---------|
What Each Metric Means
| Metric | Measures | Example |
|---|---|---|
| Statements | Percentage of executable statements run | const x = 5; is one statement |
| Branches | Percentage of if/else, switch, ternary paths taken | An if/else has 2 branches |
| Functions | Percentage of declared functions that were called | An unused helper function reduces this |
| Lines | Percentage of lines of code executed | Similar to statements but line-based |
Coverage Thresholds
Enforce minimum coverage in jest.config.js:
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
// Per-file thresholds
'./src/services/': {
branches: 90,
functions: 90
}
}
};
Coverage Guidelines
| Coverage Level | Interpretation |
|---|---|
| 90-100% | Excellent, but be careful of testing trivial code |
| 80-90% | Very good -- practical target for most projects |
| 60-80% | Acceptable for established codebases adding tests retroactively |
| Below 60% | Significant gaps in test coverage |
Warning: 100% coverage does not mean zero bugs. Coverage tells you which code was executed, not whether the assertions are meaningful. A test with no assertions gets 100% coverage but verifies nothing.
10. Focusing and Skipping Tests
During development, you often need to run only specific tests or skip broken ones temporarily.
// Run ONLY this test (all others are skipped)
it.only('should handle the specific case I am debugging', () => {
expect(calculate(42)).toBe(84);
});
// Skip this test (it will show as "skipped" in output)
it.skip('should handle edge case (TODO: fix later)', () => {
expect(brokenFunction()).toBe(true);
});
// Skip an entire describe block
describe.skip('Legacy Module (not yet migrated)', () => {
test('old test 1', () => { /* ... */ });
test('old test 2', () => { /* ... */ });
});
// Run only this describe block
describe.only('Module I am currently working on', () => {
test('new feature', () => { /* ... */ });
});
Important: Never commit
it.onlyordescribe.onlyto your repository. It will silently skip all other tests in CI. Use a lint rule or pre-commit hook to catch this.
11. Real Example: Testing Utility Functions
// utils/validators.js
function isValidEmail(email) {
if (typeof email !== 'string') return false;
const regex = /^[\w.-]+@[\w.-]+\.\w{2,}$/;
return regex.test(email);
}
function sanitizeInput(input) {
if (typeof input !== 'string') return '';
return input.trim().replace(/<[^>]*>/g, '');
}
function generateSlug(title) {
if (typeof title !== 'string') throw new TypeError('Title must be a string');
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
module.exports = { isValidEmail, sanitizeInput, generateSlug };
// utils/validators.test.js
const { isValidEmail, sanitizeInput, generateSlug } = require('./validators');
describe('isValidEmail', () => {
test('returns true for valid emails', () => {
expect(isValidEmail('user@example.com')).toBe(true);
expect(isValidEmail('name.surname@domain.co.uk')).toBe(true);
expect(isValidEmail('user123@test.org')).toBe(true);
});
test('returns false for invalid emails', () => {
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('user@')).toBe(false);
expect(isValidEmail('@domain.com')).toBe(false);
expect(isValidEmail('')).toBe(false);
});
test('returns false for non-string input', () => {
expect(isValidEmail(null)).toBe(false);
expect(isValidEmail(undefined)).toBe(false);
expect(isValidEmail(123)).toBe(false);
});
});
describe('sanitizeInput', () => {
test('removes HTML tags', () => {
expect(sanitizeInput('<script>alert("xss")</script>')).toBe('alert("xss")');
expect(sanitizeInput('Hello <b>World</b>')).toBe('Hello World');
});
test('trims whitespace', () => {
expect(sanitizeInput(' hello ')).toBe('hello');
});
test('returns empty string for non-string input', () => {
expect(sanitizeInput(null)).toBe('');
expect(sanitizeInput(42)).toBe('');
});
});
describe('generateSlug', () => {
test('converts title to URL-friendly slug', () => {
expect(generateSlug('Hello World')).toBe('hello-world');
expect(generateSlug('My Blog Post!')).toBe('my-blog-post');
expect(generateSlug(' Spaces Everywhere ')).toBe('spaces-everywhere');
});
test('handles special characters', () => {
expect(generateSlug('Node.js & Express')).toBe('node-js-express');
expect(generateSlug('100% Complete!')).toBe('100-complete');
});
test('throws TypeError for non-string input', () => {
expect(() => generateSlug(null)).toThrow(TypeError);
expect(() => generateSlug(123)).toThrow('Title must be a string');
});
});
12. Real Example: Testing a Service Layer with Mocked Database
// services/orderService.js
const Order = require('../models/Order');
const emailService = require('./emailService');
async function createOrder(userId, items) {
if (!items || items.length === 0) {
throw new Error('Order must contain at least one item');
}
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const order = await Order.create({
userId,
items,
total,
status: 'pending'
});
await emailService.sendOrderConfirmation(userId, order._id, total);
return order;
}
async function cancelOrder(orderId) {
const order = await Order.findById(orderId);
if (!order) throw new Error('Order not found');
if (order.status === 'shipped') throw new Error('Cannot cancel shipped order');
order.status = 'cancelled';
await order.save();
return order;
}
module.exports = { createOrder, cancelOrder };
// services/orderService.test.js
const { createOrder, cancelOrder } = require('./orderService');
const Order = require('../models/Order');
const emailService = require('./emailService');
// Mock both dependencies
jest.mock('../models/Order');
jest.mock('./emailService');
describe('OrderService', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('createOrder', () => {
const mockItems = [
{ name: 'Widget', price: 10, quantity: 2 },
{ name: 'Gadget', price: 25, quantity: 1 }
];
test('creates order with correct total', async () => {
const mockOrder = { _id: 'order123', userId: 'user1', items: mockItems, total: 45, status: 'pending' };
Order.create.mockResolvedValue(mockOrder);
emailService.sendOrderConfirmation.mockResolvedValue(true);
const order = await createOrder('user1', mockItems);
expect(order.total).toBe(45);
expect(Order.create).toHaveBeenCalledWith({
userId: 'user1',
items: mockItems,
total: 45,
status: 'pending'
});
});
test('sends confirmation email after creating order', async () => {
Order.create.mockResolvedValue({ _id: 'order123', total: 45 });
emailService.sendOrderConfirmation.mockResolvedValue(true);
await createOrder('user1', mockItems);
expect(emailService.sendOrderConfirmation).toHaveBeenCalledWith('user1', 'order123', 45);
});
test('throws error for empty items array', async () => {
await expect(createOrder('user1', [])).rejects.toThrow('Order must contain at least one item');
expect(Order.create).not.toHaveBeenCalled();
});
test('throws error for null items', async () => {
await expect(createOrder('user1', null)).rejects.toThrow('Order must contain at least one item');
});
});
describe('cancelOrder', () => {
test('cancels a pending order', async () => {
const mockOrder = { _id: 'order123', status: 'pending', save: jest.fn().mockResolvedValue(true) };
Order.findById.mockResolvedValue(mockOrder);
const result = await cancelOrder('order123');
expect(result.status).toBe('cancelled');
expect(mockOrder.save).toHaveBeenCalled();
});
test('throws error when order not found', async () => {
Order.findById.mockResolvedValue(null);
await expect(cancelOrder('nonexistent')).rejects.toThrow('Order not found');
});
test('throws error when trying to cancel shipped order', async () => {
const mockOrder = { _id: 'order123', status: 'shipped' };
Order.findById.mockResolvedValue(mockOrder);
await expect(cancelOrder('order123')).rejects.toThrow('Cannot cancel shipped order');
});
});
});
Key Takeaways
- Jest is zero-config: Install, add a test script, and start writing tests -- no plugins needed
- Name test files with
.test.jsor.spec.jsso Jest finds them automatically - Structure tests with
describe()for grouping andit()/test()for individual cases - Choose the right matcher:
toBefor primitives,toEqualfor objects,toThrowfor errors - Use setup/teardown hooks (
beforeEach,afterEach, etc.) to avoid duplicating test setup - Test async code with
async/awaitandrejects.toThrow()for error cases - Mock dependencies with
jest.mock()to isolate the code under test - Use
jest.fn()for standalone mocks andjest.spyOn()to spy on existing methods - Always clean up mocks with
jest.clearAllMocks()inafterEach - Coverage is a guide, not a goal: Aim for 80%+ but focus on meaningful assertions, not just hitting lines
Explain-It Challenge
Scenario: You are building a PaymentService that depends on three external services: StripeAPI (processes credit cards), InventoryService (checks stock availability), and NotificationService (sends receipts). The processPayment(userId, cartItems) function should: (1) check inventory for all items, (2) calculate the total, (3) charge the credit card via Stripe, (4) update inventory counts, and (5) send a receipt notification.
Write the complete test suite for processPayment. How do you mock each dependency? What happens when Stripe fails after inventory was checked? How do you test partial failures? What edge cases would you cover? Write at least 8 test cases.
<< Previous: 3.18.a — Introduction to Testing | Next: 3.18.c — API Testing >>