测试
概述
测试对于构建可靠的 Node.js 应用程序至关重要。本章介绍单元测试、集成测试、端到端测试、模拟以及使用 Jest、Mocha 和 Supertest 等流行框架的测试最佳实践。
使用 Jest 进行单元测试
基础 Jest 配置
javascript
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"jest": "^29.0.0",
"@types/jest": "^29.0.0"
},
"jest": {
"testEnvironment": "node",
"collectCoverageFrom": [
"src/**/*.js",
"!src/**/*.test.js"
]
}
}基础单元测试
javascript
// src/calculator.js
class Calculator {
add(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Both arguments must be numbers');
}
return a + b;
}
subtract(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Both arguments must be numbers');
}
return a - b;
}
multiply(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Both arguments must be numbers');
}
return a * b;
}
divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Both arguments must be numbers');
}
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
return a / b;
}
}
module.exports = Calculator;javascript
// src/calculator.test.js
const Calculator = require('./calculator');
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add', () => {
test('should add two positive numbers', () => {
expect(calculator.add(2, 3)).toBe(5);
});
test('should add positive and negative numbers', () => {
expect(calculator.add(5, -3)).toBe(2);
});
test('should add two negative numbers', () => {
expect(calculator.add(-2, -3)).toBe(-5);
});
test('should throw error for non-number inputs', () => {
expect(() => calculator.add('2', 3)).toThrow('Both arguments must be numbers');
expect(() => calculator.add(2, '3')).toThrow('Both arguments must be numbers');
});
});
describe('subtract', () => {
test('should subtract two numbers', () => {
expect(calculator.subtract(5, 3)).toBe(2);
});
test('should handle negative results', () => {
expect(calculator.subtract(3, 5)).toBe(-2);
});
});
describe('multiply', () => {
test('should multiply two numbers', () => {
expect(calculator.multiply(4, 3)).toBe(12);
});
test('should handle zero multiplication', () => {
expect(calculator.multiply(5, 0)).toBe(0);
});
});
describe('divide', () => {
test('should divide two numbers', () => {
expect(calculator.divide(10, 2)).toBe(5);
});
test('should throw error for division by zero', () => {
expect(() => calculator.divide(10, 0)).toThrow('Division by zero is not allowed');
});
test('should handle decimal results', () => {
expect(calculator.divide(10, 3)).toBeCloseTo(3.333, 3);
});
});
});测试异步函数
javascript
// src/user-service.js
class UserService {
constructor(database) {
this.database = database;
}
async createUser(userData) {
if (!userData.email) {
throw new Error('Email is required');
}
const existingUser = await this.database.findByEmail(userData.email);
if (existingUser) {
throw new Error('User already exists');
}
const user = {
id: Date.now(),
...userData,
createdAt: new Date()
};
await this.database.save(user);
return user;
}
async getUserById(id) {
const user = await this.database.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}
async updateUser(id, updateData) {
const user = await this.database.findById(id);
if (!user) {
throw new Error('User not found');
}
const updatedUser = { ...user, ...updateData, updatedAt: new Date() };
await this.database.update(id, updatedUser);
return updatedUser;
}
async deleteUser(id) {
const user = await this.database.findById(id);
if (!user) {
throw new Error('User not found');
}
await this.database.delete(id);
return true;
}
}
module.exports = UserService;javascript
// src/user-service.test.js
const UserService = require('./user-service');
describe('UserService', () => {
let userService;
let mockDatabase;
beforeEach(() => {
mockDatabase = {
findByEmail: jest.fn(),
findById: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
userService = new UserService(mockDatabase);
});
describe('createUser', () => {
test('should create a new user successfully', async () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
mockDatabase.findByEmail.mockResolvedValue(null);
mockDatabase.save.mockResolvedValue();
const result = await userService.createUser(userData);
expect(result).toMatchObject({
name: 'John Doe',
email: 'john@example.com'
});
expect(result.id).toBeDefined();
expect(result.createdAt).toBeInstanceOf(Date);
expect(mockDatabase.findByEmail).toHaveBeenCalledWith('john@example.com');
expect(mockDatabase.save).toHaveBeenCalledWith(result);
});
test('should throw error if email is missing', async () => {
const userData = { name: 'John Doe' };
await expect(userService.createUser(userData)).rejects.toThrow('Email is required');
expect(mockDatabase.findByEmail).not.toHaveBeenCalled();
});
test('should throw error if user already exists', async () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
const existingUser = { id: 1, email: 'john@example.com' };
mockDatabase.findByEmail.mockResolvedValue(existingUser);
await expect(userService.createUser(userData)).rejects.toThrow('User already exists');
expect(mockDatabase.save).not.toHaveBeenCalled();
});
});
describe('getUserById', () => {
test('should return user when found', async () => {
const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
mockDatabase.findById.mockResolvedValue(user);
const result = await userService.getUserById(1);
expect(result).toEqual(user);
expect(mockDatabase.findById).toHaveBeenCalledWith(1);
});
test('should throw error when user not found', async () => {
mockDatabase.findById.mockResolvedValue(null);
await expect(userService.getUserById(999)).rejects.toThrow('User not found');
});
});
describe('updateUser', () => {
test('should update user successfully', async () => {
const existingUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
const updateData = { name: 'John Smith' };
mockDatabase.findById.mockResolvedValue(existingUser);
mockDatabase.update.mockResolvedValue();
const result = await userService.updateUser(1, updateData);
expect(result.name).toBe('John Smith');
expect(result.email).toBe('john@example.com');
expect(result.updatedAt).toBeInstanceOf(Date);
expect(mockDatabase.update).toHaveBeenCalledWith(1, result);
});
});
});集成测试
使用 Supertest 进行 API 集成测试
javascript
// src/app.js
const express = require('express');
const UserService = require('./user-service');
function createApp(database) {
const app = express();
const userService = new UserService(database);
app.use(express.json());
app.post('/users', async (req, res) => {
try {
const user = await userService.createUser(req.body);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.get('/users/:id', async (req, res) => {
try {
const user = await userService.getUserById(parseInt(req.params.id));
res.json(user);
} catch (error) {
res.status(404).json({ error: error.message });
}
});
app.put('/users/:id', async (req, res) => {
try {
const user = await userService.updateUser(parseInt(req.params.id), req.body);
res.json(user);
} catch (error) {
res.status(404).json({ error: error.message });
}
});
app.delete('/users/:id', async (req, res) => {
try {
await userService.deleteUser(parseInt(req.params.id));
res.status(204).send();
} catch (error) {
res.status(404).json({ error: error.message });
}
});
return app;
}
module.exports = createApp;javascript
// src/app.test.js
const request = require('supertest');
const createApp = require('./app');
describe('User API', () => {
let app;
let mockDatabase;
beforeEach(() => {
mockDatabase = {
findByEmail: jest.fn(),
findById: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
app = createApp(mockDatabase);
});
describe('POST /users', () => {
test('should create user successfully', async () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
mockDatabase.findByEmail.mockResolvedValue(null);
mockDatabase.save.mockResolvedValue();
const response = await request(app)
.post('/users')
.send(userData)
.expect(201);
expect(response.body).toMatchObject(userData);
expect(response.body.id).toBeDefined();
});
test('should return 400 for invalid data', async () => {
const userData = { name: 'John Doe' }; // Missing email
const response = await request(app)
.post('/users')
.send(userData)
.expect(400);
expect(response.body.error).toBe('Email is required');
});
test('should return 400 for duplicate email', async () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
mockDatabase.findByEmail.mockResolvedValue({ id: 1, email: 'john@example.com' });
const response = await request(app)
.post('/users')
.send(userData)
.expect(400);
expect(response.body.error).toBe('User already exists');
});
});
describe('GET /users/:id', () => {
test('should return user when found', async () => {
const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
mockDatabase.findById.mockResolvedValue(user);
const response = await request(app)
.get('/users/1')
.expect(200);
expect(response.body).toEqual(user);
});
test('should return 404 when user not found', async () => {
mockDatabase.findById.mockResolvedValue(null);
const response = await request(app)
.get('/users/999')
.expect(404);
expect(response.body.error).toBe('User not found');
});
});
});模拟和测试替身
高级模拟技术
javascript
// src/email-service.js
const nodemailer = require('nodemailer');
class EmailService {
constructor(config) {
this.transporter = nodemailer.createTransporter(config);
}
async sendEmail(to, subject, body) {
const mailOptions = {
from: 'noreply@example.com',
to,
subject,
html: body
};
const result = await this.transporter.sendMail(mailOptions);
return result.messageId;
}
async sendWelcomeEmail(user) {
const subject = 'Welcome to our platform!';
const body = `<h1>Welcome ${user.name}!</h1><p>Thank you for joining us.</p>`;
return this.sendEmail(user.email, subject, body);
}
}
module.exports = EmailService;javascript
// src/email-service.test.js
const EmailService = require('./email-service');
const nodemailer = require('nodemailer');
// Mock nodemailer
jest.mock('nodemailer');
describe('EmailService', () => {
let emailService;
let mockTransporter;
beforeEach(() => {
mockTransporter = {
sendMail: jest.fn()
};
nodemailer.createTransporter.mockReturnValue(mockTransporter);
emailService = new EmailService({
host: 'smtp.example.com',
port: 587,
auth: { user: 'test', pass: 'test' }
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('sendEmail', () => {
test('should send email successfully', async () => {
const messageId = 'test-message-id';
mockTransporter.sendMail.mockResolvedValue({ messageId });
const result = await emailService.sendEmail(
'test@example.com',
'Test Subject',
'<p>Test Body</p>'
);
expect(result).toBe(messageId);
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
from: 'noreply@example.com',
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test Body</p>'
});
});
test('should handle email sending errors', async () => {
const error = new Error('SMTP connection failed');
mockTransporter.sendMail.mockRejectedValue(error);
await expect(emailService.sendEmail('test@example.com', 'Test', 'Body'))
.rejects.toThrow('SMTP connection failed');
});
});
describe('sendWelcomeEmail', () => {
test('should send welcome email with correct content', async () => {
const user = { name: 'John Doe', email: 'john@example.com' };
const messageId = 'welcome-message-id';
mockTransporter.sendMail.mockResolvedValue({ messageId });
const result = await emailService.sendWelcomeEmail(user);
expect(result).toBe(messageId);
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
from: 'noreply@example.com',
to: 'john@example.com',
subject: 'Welcome to our platform!',
html: '<h1>Welcome John Doe!</h1><p>Thank you for joining us.</p>'
});
});
});
});测试工具和辅助函数
测试数据库设置
javascript
// tests/test-helpers.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
class TestDatabase {
constructor() {
this.mongoServer = null;
}
async connect() {
this.mongoServer = await MongoMemoryServer.create();
const mongoUri = this.mongoServer.getUri();
await mongoose.connect(mongoUri, {
useNewUrlParser: true,
useUnifiedTopology: true
});
}
async disconnect() {
await mongoose.disconnect();
if (this.mongoServer) {
await this.mongoServer.stop();
}
}
async clearDatabase() {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
}
}
// Test data factories
class UserFactory {
static create(overrides = {}) {
return {
name: 'John Doe',
email: 'john@example.com',
age: 30,
...overrides
};
}
static createMany(count, overrides = {}) {
return Array.from({ length: count }, (_, i) =>
this.create({
...overrides,
email: `user${i}@example.com`,
name: `User ${i}`
})
);
}
}
// Custom matchers
expect.extend({
toBeValidEmail(received) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pass = emailRegex.test(received);
if (pass) {
return {
message: () => `expected ${received} not to be a valid email`,
pass: true
};
} else {
return {
message: () => `expected ${received} to be a valid email`,
pass: false
};
}
},
toBeWithinRange(received, min, max) {
const pass = received >= min && received <= max;
if (pass) {
return {
message: () => `expected ${received} not to be within range ${min} - ${max}`,
pass: true
};
} else {
return {
message: () => `expected ${received} to be within range ${min} - ${max}`,
pass: false
};
}
}
});
module.exports = {
TestDatabase,
UserFactory
};测试配置
javascript
// jest.config.js
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testMatch: [
'**/__tests__/**/*.js',
'**/?(*.)+(spec|test).js'
],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/index.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
testTimeout: 10000,
verbose: true
};javascript
// tests/setup.js
const { TestDatabase } = require('./test-helpers');
let testDb;
beforeAll(async () => {
testDb = new TestDatabase();
await testDb.connect();
});
afterAll(async () => {
if (testDb) {
await testDb.disconnect();
}
});
beforeEach(async () => {
if (testDb) {
await testDb.clearDatabase();
}
});
// Global test timeout
jest.setTimeout(10000);
// Suppress console.log in tests unless explicitly needed
global.console = {
...console,
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
};下一步
在下一章中,我们将探索 Node.js 应用程序的部署策略和生产注意事项。
关键要点
- 单元测试在隔离状态下验证单个组件
- 集成测试验证组件交互
- 模拟将测试单元与依赖项隔离
- 测试工厂简化测试数据创建
- 覆盖率指标有助于识别未测试的代码
- 适当的测试设置确保可靠和快速的测试
- 自定义匹配器提高测试可读性
- 异步测试需要适当的 Promise 处理