第 17 章 · 测试
第 17 章 · 测试
17.1 测试金字塔
/ E2E \ ← 少量,慢,价值高
/ 集成测试 \ ← 中等数量
/ 单元测试 \ ← 大量,快,成本低
| 测试类型 | 说明 | 工具 | 速度 |
|---|---|---|---|
| 单元测试 | 测试独立函数/模块 | Jest, Mocha | 毫秒 |
| 集成测试 | 测试模块间交互 | Supertest | 秒 |
| E2E 测试 | 测试完整流程 | Playwright | 秒-分钟 |
17.2 Jest
npm install --save-dev jest @types/jest
# 如使用 TypeScript
npm install --save-dev ts-jest @types/jest
配置
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/__tests__/**/*.test.js", "**/*.test.js"],
"coverageDirectory": "coverage",
"collectCoverageFrom": ["src/**/*.js", "!src/**/*.test.js"]
}
}
基本断言
// math.test.js
const { add, multiply, divide } = require('./math');
describe('math 模块', () => {
describe('add', () => {
test('两个正数相加', () => {
expect(add(1, 2)).toBe(3);
});
test('负数相加', () => {
expect(add(-1, -2)).toBe(-3);
});
});
describe('multiply', () => {
test('相乘', () => {
expect(multiply(3, 4)).toBe(12);
});
});
describe('divide', () => {
test('正常除法', () => {
expect(divide(10, 2)).toBe(5);
});
test('除以零抛出错误', () => {
expect(() => divide(10, 0)).toThrow('除数不能为零');
});
});
});
常用断言
// 相等性
expect(value).toBe(42); // 严格相等 ===
expect(value).toEqual({ a: 1 }); // 深度相等
expect(value).not.toBe(null);
// 真值
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect(value).toBeNaN();
// 数字
expect(value).toBeGreaterThan(0);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(100);
expect(value).toBeCloseTo(3.14, 1); // 浮点数比较
// 字符串
expect(str).toMatch(/hello/i);
expect(str).toContain('world');
// 数组和可迭代
expect(arr).toContain(3);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));
// 对象
expect(obj).toHaveProperty('name');
expect(obj).toHaveProperty('age', 30);
expect(obj).toMatchObject({ name: 'Alice' });
// 函数
expect(fn).toThrow();
expect(fn).toThrow('错误消息');
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
expect(mockFn).toHaveBeenCalledTimes(3);
Mock 和 Spy
// Mock 函数
const mockFn = jest.fn();
mockFn('hello');
expect(mockFn).toHaveBeenCalledWith('hello');
// 带返回值的 Mock
const mockGet = jest.fn().mockReturnValue('value');
const mockAsync = jest.fn().mockResolvedValue({ data: 'test' });
// Mock 模块
jest.mock('./database', () => ({
findUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
createUser: jest.fn().mockResolvedValue({ id: 2 }),
}));
// Spy
const spy = jest.spyOn(console, 'log');
console.log('test');
expect(spy).toHaveBeenCalledWith('test');
spy.mockRestore();
// Mock 实现
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn(),
}));
const fs = require('fs/promises');
fs.readFile.mockResolvedValue('file content');
// 定时器 Mock
jest.useFakeTimers();
jest.advanceTimersByTime(1000);
jest.useRealTimers();
生命周期钩子
describe('测试套件', () => {
beforeAll(async () => {
// 整个套件开始前执行一次
await setupDatabase();
});
afterAll(async () => {
// 整个套件结束后执行一次
await cleanupDatabase();
});
beforeEach(() => {
// 每个测试前执行
resetMocks();
});
afterEach(() => {
// 每个测试后执行
jest.clearAllMocks();
});
test('测试 1', () => { /* ... */ });
test('测试 2', () => { /* ... */ });
});
异步测试
// 回调方式
test('异步回调', (done) => {
fetchData((data) => {
expect(data).toBe('result');
done();
});
});
// Promise 方式
test('异步 Promise', () => {
return fetchData().then(data => {
expect(data).toBe('result');
});
});
// async/await(推荐)
test('异步 async/await', async () => {
const data = await fetchData();
expect(data).toBe('result');
});
// 测试异步错误
test('异步错误', async () => {
await expect(fetchData('invalid')).rejects.toThrow('无效参数');
});
17.3 Supertest(集成测试)
npm install --save-dev supertest
const request = require('supertest');
const app = require('./app');
describe('用户 API', () => {
test('GET /api/users — 获取用户列表', async () => {
const res = await request(app)
.get('/api/users')
.expect(200);
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.pagination).toBeDefined();
});
test('POST /api/users — 创建用户', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Test', email: '[email protected]' })
.set('Content-Type', 'application/json')
.expect(201);
expect(res.body.data.name).toBe('Test');
expect(res.body.data.id).toBeDefined();
});
test('POST /api/users — 验证失败', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: '' })
.expect(400);
expect(res.body.errors).toBeDefined();
});
test('认证接口', async () => {
// 先登录获取 token
const loginRes = await request(app)
.post('/api/login')
.send({ email: '[email protected]', password: 'password' })
.expect(200);
const token = loginRes.body.accessToken;
// 使用 token 访问受保护接口
const res = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body.user.email).toBe('[email protected]');
});
});
17.4 代码覆盖率
npm test -- --coverage
覆盖率报告:
| 指标 | 说明 | 目标 |
|---|---|---|
| Statements | 语句覆盖率 | > 80% |
| Branches | 分支覆盖率 | > 75% |
| Functions | 函数覆盖率 | > 80% |
| Lines | 行覆盖率 | > 80% |
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
},
};
17.5 内置测试运行器(Node.js 20+)
// Node.js 内置测试(无需安装 Jest)
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert');
describe('math', () => {
it('should add numbers', () => {
assert.strictEqual(add(1, 2), 3);
});
it('should throw on divide by zero', () => {
assert.throws(() => divide(1, 0), /除数不能为零/);
});
});
node --test test/**/*.test.js
注意事项
⚠️ 测试应该是独立的:每个测试不依赖其他测试的执行顺序或外部状态。
⚠️ 避免测试实现细节:测试行为(输入 → 输出),而非内部实现。
⚠️ Mock 最小化:只 Mock 外部依赖(数据库、API),不要 Mock 被测模块内部。
⚠️ CI 中必须运行测试:每次提交都应自动运行测试套件。
业务场景
- API 测试:使用 Supertest 验证所有 API 端点的行为
- 数据库测试:使用内存数据库(如 SQLite)进行隔离测试
- 回归测试:每次修复 Bug 后添加对应的测试用例
- TDD 开发:先写测试再写实现
扩展阅读
上一章:第 16 章 · WebSocket 实时通信 下一章:第 18 章 · 日志 — Winston、Pino、日志级别和结构化日志。