跳到主要内容

测试

测试是保证代码质量的重要手段。良好的测试覆盖可以帮助你及时发现 bug、安全重构代码,并为团队协作提供信心。本章将系统介绍 Express 应用的测试方法和最佳实践。

为什么测试很重要?

  1. 发现 Bug 更早:在开发阶段发现问题,成本最低
  2. 重构更安全:有测试保护,重构时不用担心破坏现有功能
  3. 文档作用:测试用例是最好的代码文档
  4. 设计指导:编写测试促使你思考 API 设计
  5. 团队协作:测试用例可以帮助团队成员理解代码行为

测试类型

单元测试

测试单个函数或模块的行为,是最基础的测试类型。

集成测试

测试多个模块协作的行为,如路由、数据库交互。

端到端测试(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 应用的测试方法:

  1. 测试类型:单元测试、集成测试、端到端测试
  2. 测试工具:Jest、Supertest、mongodb-memory-server
  3. 单元测试:测试独立函数和模块
  4. 集成测试:测试 API 路由和数据库交互
  5. Mock 和 Spy:模拟外部依赖、监视函数调用
  6. 测试覆盖率:衡量测试完整性
  7. 最佳实践:命名、组织、隔离、边界条件
  8. CI/CD 集成:自动化测试流程

良好的测试习惯能显著提升代码质量和开发效率。

练习

  1. 为一个计算器模块编写单元测试,覆盖加减乘除和各种边界条件
  2. 为用户注册 API 编写集成测试,包括成功和各种失败场景
  3. 使用 Mock 测试一个发送邮件的功能
  4. 配置 Jest 覆盖率阈值,要求全局覆盖率达到 80%
  5. 将测试集成到 GitHub Actions 中,实现自动化测试

参考资料