Unit tests in Express test individual pieces of logic in isolation โ utility functions like sendEmail, model methods like comparePassword, and validator functions โ without starting the HTTP server or touching a database. Jest is the standard test runner for Node.js: it discovers test files, runs them in parallel, provides a rich assertion API, and generates coverage reports. In this lesson you will configure Jest for the Express server, write your first unit tests, and learn how to mock external dependencies so tests run fast and reliably without network calls or database connections.
Jest Setup for the Express Server
cd server
npm install --save-dev jest @types/jest
// server/package.json โ add test configuration
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/__tests__/**/*.test.js", "**/*.test.js"],
"collectCoverageFrom": ["src/**/*.js", "!src/**/*.test.js"],
"coverageThreshold": {
"global": { "branches": 60, "functions": 70, "lines": 70, "statements": 70 }
}
}
}
jest.clearAllMocks() in beforeEach or configure "clearMocks": true in the Jest config.src/utils/sendEmail.js gets src/utils/sendEmail.test.js (or src/__tests__/utils/sendEmail.test.js). This co-location makes it immediately clear what each test file covers and easy to find tests when editing source files. VS Code’s Jest extension can run individual tests from within the test file without switching to the terminal.Testing Utility Functions
// server/src/utils/validators.test.js
const { validateEmail, validatePassword } = require('./validators');
describe('validateEmail', () => {
test('accepts valid email addresses', () => {
expect(validateEmail('user@example.com')).toBe(true);
expect(validateEmail('user+tag@domain.co.uk')).toBe(true);
});
test('rejects invalid email addresses', () => {
expect(validateEmail('notanemail')).toBe(false);
expect(validateEmail('@nodomain')).toBe(false);
expect(validateEmail('')).toBe(false);
expect(validateEmail(null)).toBe(false);
});
});
describe('validatePassword', () => {
test('rejects passwords shorter than 8 characters', () => {
expect(validatePassword('short')).toBe(false);
});
test('accepts passwords of 8 or more characters', () => {
expect(validatePassword('validPass1!')).toBe(true);
expect(validatePassword('12345678')).toBe(true);
});
});
Testing Mongoose Model Methods
// server/src/__tests__/models/User.test.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const User = require('../../models/User');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(async () => {
await User.deleteMany({}); // clean slate between tests
});
describe('User model', () => {
test('hashes password before saving', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
password: 'plaintext123',
});
expect(user.password).not.toBe('plaintext123'); // stored as hash
expect(user.password).toMatch(/^\$2[ab]\$/); // bcrypt hash format
});
test('comparePassword returns true for correct password', async () => {
const user = await User.create({
name: 'Test', email: 'test2@example.com', password: 'correctPass!',
});
const result = await user.comparePassword('correctPass!');
expect(result).toBe(true);
});
test('comparePassword returns false for wrong password', async () => {
const user = await User.create({
name: 'Test', email: 'test3@example.com', password: 'correctPass!',
});
const result = await user.comparePassword('wrongPass');
expect(result).toBe(false);
});
test('requires name, email, and password', async () => {
await expect(User.create({})).rejects.toThrow(/validation failed/i);
});
test('rejects duplicate email', async () => {
await User.create({ name: 'A', email: 'dup@example.com', password: 'pass1234' });
await expect(
User.create({ name: 'B', email: 'dup@example.com', password: 'pass1234' })
).rejects.toThrow(/duplicate key/i);
});
});
Mocking External Dependencies
// Mock nodemailer so tests do not send real emails
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => ({
sendMail: jest.fn().mockResolvedValue({ messageId: 'test-id' }),
})),
createTestAccount: jest.fn().mockResolvedValue({
user: 'test@ethereal.email',
pass: 'testpass',
}),
}));
// Mock cloudinary
jest.mock('cloudinary', () => ({
v2: {
config: jest.fn(),
uploader: {
upload: jest.fn().mockResolvedValue({ secure_url: 'https://res.cloudinary.com/test.jpg' }),
destroy: jest.fn().mockResolvedValue({ result: 'ok' }),
},
},
}));
// Test that uses the mocked modules
const sendEmail = require('../utils/sendEmail');
test('sendEmail calls transporter.sendMail with correct options', async () => {
await sendEmail({ to: 'user@example.com', subject: 'Test', text: 'Hello', html: '<p>Hello</p>' });
// Verify the email was "sent" (mock called)
const { createTransport } = require('nodemailer');
const transporter = createTransport.mock.results[0].value;
expect(transporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({ to: 'user@example.com', subject: 'Test' })
);
});
Common Jest Matchers
| Matcher | Use |
|---|---|
toBe(value) |
Strict equality (===) |
toEqual(obj) |
Deep equality โ for objects/arrays |
toMatch(/regex/) |
String matches regex |
toThrow(/msg/) |
Function throws matching error |
resolves.toBe() |
Promise resolves to value |
rejects.toThrow() |
Promise rejects with error |
toHaveBeenCalled() |
Mock was called at least once |
toHaveBeenCalledWith() |
Mock called with specific args |
expect.objectContaining() |
Object has at least these keys/values |
expect.stringContaining() |
String includes substring |
Common Mistakes
Mistake 1 โ Not cleaning up between tests
โ Wrong โ test data persists and causes later tests to fail:
test('creates a user', async () => {
await User.create({ email: 'test@example.com', ... });
// Next test: 'duplicate email' test fails because this user still exists!
โ Correct โ clean up in afterEach:
afterEach(async () => { await User.deleteMany({}); }); // โ clean slate
Mistake 2 โ Not awaiting async test assertions
โ Wrong โ test passes before the async assertion runs:
test('rejects duplicate email', () => { // missing async!
expect(User.create({ email: 'dup@example.com' })).rejects.toThrow();
// Promise not awaited โ test passes even if it rejects with wrong error
});
โ Correct โ use async/await or return the promise:
test('rejects duplicate email', async () => {
await expect(User.create({ email: 'dup@example.com' })).rejects.toThrow(); // โ
});
Mistake 3 โ Mocking too much
โ Wrong โ mocking the module under test:
jest.mock('../utils/validateEmail');
test('validates email', () => { ... }); // testing the mock, not the real function!
โ Correct โ only mock external dependencies (database, network, email). Test the code you wrote.
Quick Reference
| Task | Code |
|---|---|
| Run tests | npm test |
| Watch mode | npm run test:watch |
| Coverage | npm run test:coverage |
| Mock a module | jest.mock('moduleName', () => ({ ... })) |
| In-memory DB | MongoMemoryServer.create() |
| Clean up DB | afterEach(async () => Model.deleteMany({})) |
| Expect async throw | await expect(fn()).rejects.toThrow(/msg/) |