Node.js Testing: Jest, Mocha, and Supertest
이 글의 핵심
Hands-on Node.js testing: Jest for unit and async tests, Supertest for HTTP APIs, mocking axios and nodemailer, MongoDB in-memory setup, integration flows for auth, coverage gates, and Mocha with Chai.
Introduction
Why test?
Testing verifies that code behaves as intended.
Benefits:
- ✅ Catch bugs early: Before production
- ✅ Safer refactors: Regression safety net
- ✅ Documentation: Examples of usage
- ✅ Confidence: Ship changes with less fear
- ✅ Maintenance: Saves time over the long run
Kinds of tests:
- Unit: Single functions or classes
- Integration: Multiple modules together
- E2E: Full system paths
1. 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
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');
});
});
2. 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();
});
});
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');
});
});
3. 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);
});
});
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');
});
});
Spies
describe('Spies', () => {
test('wraps a method', () => {
const obj = { method: () => 'original' };
const spy = jest.spyOn(obj, 'method');
obj.method();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
4. 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;
// 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');
});
});
});
5. 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/']
};
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();
});
});
6. 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');
});
});
});
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);
});
});
});
7. 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);
});
});
});
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');
});
});
8. 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
}
}
};
9. 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');
});
});
});
Auth API (extended)
See integration example above; add JWT verification checks that match your auth middleware.
10. Test doubles
Mock (email)
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)
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');
});
});
11. 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 };
Shared state
Use beforeEach to reset fixtures; avoid module-level mutable arrays unless cleared.
DB cleanup
afterEach(async () => {
await User.deleteMany({});
await Post.deleteMany({});
});
12. Practical tips
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 });
}
TDD sketch
Write a failing test, minimal pass, refactor, add edge-case tests.
Summary
Takeaways
- Jest: Default all-in-one choice
- Mocha + Chai: Flexible stack
- Supertest: HTTP-level API tests
- Mocks: Isolate I/O and time
- Coverage: Enforce thresholds thoughtfully
- TDD: Red–green–refactor when it helps
Framework comparison
| Framework | Pros | Cons |
|---|---|---|
| Jest | Batteries included, snapshots | Heavier |
| Mocha | Minimal core | Needs plugins |
| AVA | Parallel by default | Smaller ecosystem |
Practices
- Independent tests
- One main assertion per test (rule of thumb)
- Descriptive names
- Arrange–Act–Assert
- Test failure paths
- Mock external systems
Next steps
- Node.js deployment
- Node.js performance
- CI/CD pipelines
Resources
Related posts
- Node.js Getting Started