测试
测试是保证代码质量的重要手段。良好的测试覆盖可以帮助你及时发现 bug、安全重构代码,并为团队协作提供信心。本章将系统介绍 Express 应用的测试方法和最佳实践。
为什么测试很重要?
- 发现 Bug 更早:在开发阶段发现问题,成本最低
- 重构更安全:有测试保护,重构时不用担心破坏现有功能
- 文档作用:测试用例是最好的代码文档
- 设计指导:编写测试促使你思考 API 设计
- 团队协作:测试用例可以帮助团队成员理解代码行为
测试类型
单元测试
测试单个函数或模块的行为,是最基础的测试类型。
集成测试
测试多个模块协作的行为,如路由、数据库交互。
端到端测试(E2E)
模拟用户行为,测试完整的业务流程。
测试工具
Jest
Jest 是 Facebook 开发的 JavaScript 测试框架,具有零配置、快照测试、并行执行等特点。
npm install -D jest
Supertest
Supertest 用于测试 HTTP 服务器,是 Express 集成测试的利器。
npm install -D supertest
mongodb-memory-server
用于测试的内存 MongoDB 实例,每次测试后数据自动清空。
npm install -D mongodb-memory-server
Jest 配置
基本配置
创建 jest.config.js:
module.exports = {
// 测试环境
testEnvironment: 'node',
// 测试文件匹配模式
testMatch: ['**/*.test.js'],
// 忽略的路径
coveragePathIgnorePatterns: ['/node_modules/'],
// 覆盖率收集目录
coverageDirectory: 'coverage',
// 设置文件
setupFilesAfterEnv: ['./tests/setup.js'],
// 测试超时时间(毫秒)
testTimeout: 10000,
// 详细输出
verbose: true
};
package.json 脚本
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
测试环境设置
测试数据库配置
创建 tests/setup.js:
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
// 所有测试前执行一次
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
// 所有测试后执行一次
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
// 每个测试后清空数据库
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
测试应用工厂
创建可复用的测试应用实例:
// tests/utils/testApp.js
const express = require('express');
const mongoose = require('mongoose');
const createTestApp = (routes) => {
const app = express();
app.use(express.json());
app.use('/api', routes);
// 错误处理
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({
error: err.message
});
});
return app;
};
module.exports = createTestApp;
单元测试
单元测试关注独立的功能单元,不依赖外部服务。
测试工具函数
// utils/calculator.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => {
if (b === 0) throw new Error('不能除以零');
return a / b;
};
module.exports = { add, subtract, multiply, divide };
// utils/calculator.test.js
const { add, subtract, multiply, divide } = require('./calculator');
describe('Calculator', () => {
describe('add', () => {
test('应该正确计算两数之和', () => {
expect(add(1, 2)).toBe(3);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
});
describe('subtract', () => {
test('应该正确计算两数之差', () => {
expect(subtract(5, 3)).toBe(2);
expect(subtract(1, 5)).toBe(-4);
});
});
describe('multiply', () => {
test('应该正确计算两数之积', () => {
expect(multiply(2, 3)).toBe(6);
expect(multiply(-2, 3)).toBe(-6);
expect(multiply(0, 5)).toBe(0);
});
});
describe('divide', () => {
test('应该正确计算两数之商', () => {
expect(divide(6, 2)).toBe(3);
expect(divide(5, 2)).toBe(2.5);
});
test('除数为零时应该抛出错误', () => {
expect(() => divide(1, 0)).toThrow('不能除以零');
});
});
});
测试中间件
// middleware/auth.test.js
const authMiddleware = require('./auth');
describe('Auth Middleware', () => {
let req, res, next;
beforeEach(() => {
req = { headers: {} };
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
});
test('没有 token 时应返回 401', () => {
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: '请先登录' });
expect(next).not.toHaveBeenCalled();
});
test('无效 token 时应返回 401', () => {
req.headers.authorization = 'Bearer invalid-token';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
test('有效 token 时应调用 next', () => {
req.headers.authorization = 'Bearer valid-token';
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user).toBeDefined();
});
});
测试自定义错误类
// errors/AppError.test.js
const { AppError, NotFoundError, ValidationError } = require('./index');
describe('Custom Errors', () => {
test('AppError 应包含正确的属性', () => {
const error = new AppError('测试错误', 400);
expect(error.message).toBe('测试错误');
expect(error.statusCode).toBe(400);
expect(error.status).toBe('fail');
expect(error.isOperational).toBe(true);
});
test('NotFoundError 应有 404 状态码', () => {
const error = new NotFoundError('用户不存在');
expect(error.statusCode).toBe(404);
expect(error.message).toBe('用户不存在');
});
test('ValidationError 应有 400 状态码', () => {
const error = new ValidationError('邮箱格式不正确');
expect(error.statusCode).toBe(400);
expect(error.message).toBe('邮箱格式不正确');
});
});
集成测试
集成测试验证多个组件协作的正确性,通常涉及数据库操作。
测试用户 API
// tests/routes/users.test.js
const request = require('supertest');
const app = require('../../src/app');
const User = require('../../src/models/User');
describe('Users API', () => {
describe('GET /api/users', () => {
test('应该返回用户列表', async () => {
// 准备测试数据
await User.create([
{ username: 'user1', email: '[email protected]', password: 'password' },
{ username: 'user2', email: '[email protected]', password: 'password' }
]);
const res = await request(app).get('/api/users');
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(2);
expect(res.body.data[0]).toHaveProperty('username');
expect(res.body.data[0]).not.toHaveProperty('password');
});
test('应该支持分页', async () => {
// 创建 25 个用户
const users = Array.from({ length: 25 }, (_, i) => ({
username: `user${i}`,
email: `user${i}@test.com`,
password: 'password'
}));
await User.create(users);
const res = await request(app)
.get('/api/users')
.query({ page: 2, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(10);
expect(res.body.pagination.page).toBe(2);
expect(res.body.pagination.total).toBe(25);
});
test('应该支持搜索', async () => {
await User.create([
{ username: 'john', email: '[email protected]', password: 'password' },
{ username: 'jane', email: '[email protected]', password: 'password' },
{ username: 'bob', email: '[email protected]', password: 'password' }
]);
const res = await request(app)
.get('/api/users')
.query({ search: 'j' });
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(2);
});
});
describe('GET /api/users/:id', () => {
test('应该返回单个用户', async () => {
const user = await User.create({
username: 'testuser',
email: '[email protected]',
password: 'password'
});
const res = await request(app).get(`/api/users/${user._id}`);
expect(res.status).toBe(200);
expect(res.body.data.username).toBe('testuser');
});
test('无效 ID 应返回 400', async () => {
const res = await request(app).get('/api/users/invalid-id');
expect(res.status).toBe(400);
});
test('不存在的用户应返回 404', async () => {
const fakeId = new mongoose.Types.ObjectId();
const res = await request(app).get(`/api/users/${fakeId}`);
expect(res.status).toBe(404);
});
});
describe('POST /api/users', () => {
test('应该创建新用户', async () => {
const userData = {
username: 'newuser',
email: '[email protected]',
password: 'password123'
};
const res = await request(app)
.post('/api/users')
.send(userData);
expect(res.status).toBe(201);
expect(res.body.data.username).toBe(userData.username);
expect(res.body.data).not.toHaveProperty('password');
// 验证数据库
const user = await User.findOne({ email: userData.email });
expect(user).toBeTruthy();
});
test('缺少必填字段应返回 400', async () => {
const res = await request(app)
.post('/api/users')
.send({ username: 'test' });
expect(res.status).toBe(400);
});
test('重复邮箱应返回 409', async () => {
await User.create({
username: 'existing',
email: '[email protected]',
password: 'password'
});
const res = await request(app)
.post('/api/users')
.send({
username: 'new',
email: '[email protected]',
password: 'password'
});
expect(res.status).toBe(409);
});
});
describe('PUT /api/users/:id', () => {
test('应该更新用户', async () => {
const user = await User.create({
username: 'oldname',
email: '[email protected]',
password: 'password'
});
const res = await request(app)
.put(`/api/users/${user._id}`)
.send({ username: 'newname' });
expect(res.status).toBe(200);
expect(res.body.data.username).toBe('newname');
});
});
describe('DELETE /api/users/:id', () => {
test('应该删除用户', async () => {
const user = await User.create({
username: 'todelete',
email: '[email protected]',
password: 'password'
});
const res = await request(app).delete(`/api/users/${user._id}`);
expect(res.status).toBe(204);
// 验证数据库
const deletedUser = await User.findById(user._id);
expect(deletedUser).toBeNull();
});
});
});
测试认证 API
// tests/routes/auth.test.js
const request = require('supertest');
const app = require('../../src/app');
const User = require('../../src/models/User');
const jwt = require('jsonwebtoken');
describe('Auth API', () => {
describe('POST /api/auth/register', () => {
test('应该注册新用户并返回 token', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
username: 'newuser',
email: '[email protected]',
password: 'password123'
});
expect(res.status).toBe(201);
expect(res.body.token).toBeDefined();
expect(res.body.user.username).toBe('newuser');
});
test('无效邮箱应返回 400', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
username: 'test',
email: 'invalid-email',
password: 'password'
});
expect(res.status).toBe(400);
});
test('密码太短应返回 400', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
username: 'test',
email: '[email protected]',
password: '123'
});
expect(res.status).toBe(400);
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
await User.create({
username: 'testuser',
email: '[email protected]',
password: 'password123'
});
});
test('正确的凭据应该登录成功', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'password123'
});
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
test('错误的密码应该返回 401', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'wrongpassword'
});
expect(res.status).toBe(401);
});
test('不存在的用户应该返回 401', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'password'
});
expect(res.status).toBe(401);
});
});
describe('受保护的路由', () => {
let token;
beforeEach(async () => {
const user = await User.create({
username: 'testuser',
email: '[email protected]',
password: 'password123'
});
token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '1h' });
});
test('有效 token 应该可以访问', async () => {
const res = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
});
test('无 token 应该返回 401', async () => {
const res = await request(app).get('/api/profile');
expect(res.status).toBe(401);
});
test('无效 token 应该返回 401', async () => {
const res = await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer invalid-token');
expect(res.status).toBe(401);
});
});
});
Mock 和 Spy
使用 Jest Mock
// mock 外部模块
jest.mock('nodemailer');
const nodemailer = require('nodemailer');
const { sendEmail } = require('./email');
describe('Email Service', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('应该发送邮件', 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'
});
});
test('发送失败应该抛出错误', async () => {
const mockSendMail = jest.fn().mockRejectedValue(new Error('SMTP Error'));
nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail });
await expect(sendEmail('[email protected]', 'Subject', 'Body'))
.rejects.toThrow('SMTP Error');
});
});
Spy 监视函数调用
// spy.test.js
const calculator = require('./calculator');
describe('Calculator with Spy', () => {
test('应该调用 add 函数', () => {
const spy = jest.spyOn(calculator, 'add');
calculator.add(1, 2);
expect(spy).toHaveBeenCalledWith(1, 2);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore(); // 恢复原始实现
});
test('应该模拟 add 的返回值', () => {
const spy = jest.spyOn(calculator, 'add').mockReturnValue(100);
const result = calculator.add(1, 2);
expect(result).toBe(100);
spy.mockRestore();
});
});
Mock 定时器
describe('Timer Functions', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('应该在延迟后执行回调', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
// 快进时间
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
test('setInterval 应该重复执行', () => {
const callback = jest.fn();
setInterval(callback, 100);
jest.advanceTimersByTime(350);
expect(callback).toHaveBeenCalledTimes(3);
});
});
测试覆盖率
运行覆盖率报告
npm run test:coverage
覆盖率报告示例
-----------------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------------|---------|----------|---------|---------|
All files | 85.71 | 75.00 | 90.00 | 85.71 |
controllers/ | 88.89 | 80.00 | 85.71 | 88.89 |
authController.js | 90.00 | 85.71 | 100.00 | 90.00 |
userController.js | 87.50 | 75.00 | 80.00 | 87.50 |
middleware/ | 80.00 | 66.67 | 100.00 | 80.00 |
auth.js | 85.00 | 70.00 | 100.00 | 85.00 |
errorHandler.js | 75.00 | 60.00 | 100.00 | 75.00 |
models/ | 100.00 | 100.00 | 100.00 | 100.00 |
User.js | 100.00 | 100.00 | 100.00 | 100.00 |
-----------------------------|---------|----------|---------|---------|
配置覆盖率阈值
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 70,
functions: 80,
lines: 80,
statements: 80
}
}
};
测试最佳实践
1. 测试命名清晰
// 不好
test('test1', () => { ... });
// 好
test('用户不存在时应该返回 404 错误', () => { ... });
2. 使用 describe 组织测试
describe('Users API', () => {
describe('GET /api/users', () => {
test('应该返回用户列表', () => { ... });
test('应该支持分页', () => { ... });
});
describe('POST /api/users', () => {
test('应该创建新用户', () => { ... });
test('缺少必填字段应该返回 400', () => { ... });
});
});
3. 使用 AAA 模式
test('应该正确计算总价', () => {
// Arrange(准备)
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 3 }
];
// Act(执行)
const total = calculateTotal(items);
// Assert(断言)
expect(total).toBe(350);
});
4. 测试边界条件
describe('divide', () => {
test('正常情况', () => {
expect(divide(6, 2)).toBe(3);
});
test('除数为零', () => {
expect(() => divide(1, 0)).toThrow();
});
test('负数', () => {
expect(divide(-6, 2)).toBe(-3);
});
test('浮点数', () => {
expect(divide(5, 2)).toBeCloseTo(2.5);
});
test('大数', () => {
expect(divide(Number.MAX_SAFE_INTEGER, 2)).toBe(Number.MAX_SAFE_INTEGER / 2);
});
});
5. 隔离测试
// 每个测试都应该独立运行
afterEach(async () => {
// 清理数据库
await User.deleteMany({});
// 清理 mock
jest.clearAllMocks();
});
6. 测试错误路径
describe('用户创建', () => {
test('成功场景', async () => {
const res = await request(app)
.post('/api/users')
.send(validUserData);
expect(res.status).toBe(201);
});
test('缺少必填字段', async () => {
const res = await request(app)
.post('/api/users')
.send({});
expect(res.status).toBe(400);
});
test('重复邮箱', async () => {
await User.create(existingUser);
const res = await request(app)
.post('/api/users')
.send(existingUser);
expect(res.status).toBe(409);
});
test('无效邮箱格式', async () => {
const res = await request(app)
.post('/api/users')
.send({ ...validUserData, email: 'invalid' });
expect(res.status).toBe(400);
});
});
7. 使用测试工厂
// tests/factories/userFactory.js
const User = require('../../src/models/User');
const createUser = async (overrides = {}) => {
const defaults = {
username: `user_${Date.now()}`,
email: `user_${Date.now()}@test.com`,
password: 'password123'
};
return User.create({ ...defaults, ...overrides });
};
module.exports = { createUser };
// 使用
test('用户应该有默认角色', async () => {
const user = await createUser();
expect(user.role).toBe('user');
});
test('管理员应该有额外权限', async () => {
const admin = await createUser({ role: 'admin' });
expect(admin.role).toBe('admin');
});
CI/CD 集成
GitHub Actions
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6
ports:
- 27017:27017
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:ci
env:
MONGODB_URI: mongodb://localhost:27017/test
JWT_SECRET: test-secret-key
NODE_ENV: test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
小结
本章系统介绍了 Express 应用的测试方法:
- 测试类型:单元测试、集成测试、端到端测试
- 测试工具:Jest、Supertest、mongodb-memory-server
- 单元测试:测试独立函数和模块
- 集成测试:测试 API 路由和数据库交互
- Mock 和 Spy:模拟外部依赖、监视函数调用
- 测试覆盖率:衡量测试完整性
- 最佳实践:命名、组织、隔离、边界条件
- CI/CD 集成:自动化测试流程
良好的测试习惯能显著提升代码质量和开发效率。
练习
- 为一个计算器模块编写单元测试,覆盖加减乘除和各种边界条件
- 为用户注册 API 编写集成测试,包括成功和各种失败场景
- 使用 Mock 测试一个发送邮件的功能
- 配置 Jest 覆盖率阈值,要求全局覆盖率达到 80%
- 将测试集成到 GitHub Actions 中,实现自动化测试