现在我开始撰写第9章 9
现在我开始撰写第9章 9.2 的完整教程。
第9章 9.2 测试:Jest + Supertest
目标:读完本文,你能用 Jest 写单元测试、用 Supertest 测试 API 接口,理解什么是 mock 和覆盖率。
预计用时:90 分钟(按节奏走,每段都能复制运行)
🎯 开场 3 分钟:为什么要学这个?
场景:你花了一周写了 500 行代码,交给了测试同学。测试同学跑了一下,说「有 12 个 bug」。你一个个改完,再跑,还有 5 个 bug。改完再跑,还有 3 个……
痛点来了:
- 每次改代码都不知道会不会扯坏别的功能——手动回归测试累死人
- 代码上线后才发现 bug,运维半夜给你打电话——线上事故
- 别人接手你的代码,改一行要猜半天这行干啥的——没有文档,代码会变谜语
学完本文你能做什么:
- 给每个函数写「自动体检单」,改代码后一键跑完所有检查
- 模拟测试 API 接口,不用真的起一个服务器
- 用 mock 隔离外部依赖(比如数据库、第三方 API),让测试跑得又快又稳
上一章我们学了 TypeScript 给代码加类型保障,这一章我们给代码加「测试保障」——让 bug 在本地就被抓出来,而不是等到用户投诉。
🧱 基础 25 分钟:核心概念(小白视角)
2.1 什么是测试?为什么需要自动测试?
想象你开了一家餐厅:
- 人工测试 = 每道菜端出去,服务员要站在桌边看着你吃,看你皱眉头就记下来——慢、贵、容易漏
- 自动测试 = 给厨房装一套传感器,菜一出锅就自动检测温度、颜色、摆盘——快、准、不漏
说白了:自动测试就是「用代码检测代码」,你写一次,以后每次改代码都能自动跑一遍,确保没扯坏原有的功能。

2.2 Jest 是什么?
Jest 是 Node.js 里最流行的测试框架,Facebook 出品,Vue、React、TypeScript 项目都在用。
生活类比:Jest 像一个「自动阅卷老师」——你写好试题(测试用例),Jest 自动帮你批卷打分。
最简例子:测试一个加法函数
先建一个项目,安装 Jest:
npm init -y
npm install --save-dev jest
写一个待测的 math.js:
// math.js
function add(a, b) {
return a + b;
}
module.exports = { add };
写测试文件 math.test.js(注意 .test.js 后缀是 Jest 约定):
// math.test.js
const { add } = require('./math');
// 测试用例:expect(实际值).toBe(期望值)
test('1 + 2 应该等于 3', () => {
expect(add(1, 2)).toBe(3);
});
test('-1 + 1 应该等于 0', () => {
expect(add(-1, 1)).toBe(0);
});
跑一下:
npx jest
预期输出:
PASS math.test.js
✓ 1 + 2 应该等于 3 (3ms)
✓ -1 + 1 应该等于 0 (1ms)
test()是 Jest 的测试函数,第一个参数是测试描述,第二个是测试函数expect(x).toBe(y)断言 x 等于 y,不相等就报错
2.3 describe 和 it:组织测试的结构
当测试多了,需要分组。用 describe 把相关测试放一起,用 it 或 test 写单个用例。
// string.test.js
const { reverseString } = require('./string');
describe('字符串处理', () => {
describe('reverseString', () => {
it('应该反转 "hello" 为 "olleh"', () => {
expect(reverseString('hello')).toBe('olleh');
});
it('应该反转 "abc" 为 "cba"', () => {
expect(reverseString('abc')).toBe('cba');
});
});
});
输出:
字符串处理
reverseString
✓ 应该反转 "hello" 为 "olleh"
✓ 应该反转 "abc" 为 "cba"
describe('组名', () => { ... }):分组,让输出更清晰it('描述', () => { ... }):单个测试用例,和test()等价
2.4 常用断言:不只是 toBe
Jest 提供了很多断言方法,覆盖各种场景:
test('常用断言演示', () => {
// 相等
expect(1 + 1).toBe(2);
expect([1, 2, 3]).toEqual([1, 2, 3]); // 数组/对象用 toEqual 深比较
// 真假
expect(true).toBeTruthy();
expect(null).toBeFalsy();
expect(undefined).toBeUndefined();
// 数字
expect(10).toBeGreaterThan(5);
expect(10).toBeLessThan(20);
expect(3.14).toBeCloseTo(3.14); // 浮点数比较用 toBeCloseTo
// 包含
expect('hello world').toContain('hello');
expect([1, 2, 3]).toContain(2);
// 正则
expect('abc@example.com').toMatch(/@.*\.com/);
// 例外
expect(() => JSON.parse('invalid')).toThrow(); // 应该有异常
});
记忆口诀:toBe 是精确相等,toEqual 是深度相等(对象/数组);toBeTruthy 判断真假,toBeNull 判断空值。
2.5 beforeEach / afterEach:每个测试前后的准备
很多测试需要「准备环境」——比如创建数据、连接数据库。beforeEach 和 afterEach 帮你自动做这件事。
// user.test.js
const { createUser, deleteUser } = require('./user');
let userId;
beforeEach(async () => {
// 每个测试前:创建一个测试用户
userId = await createUser({ name: '测试用户', age: 25 });
});
afterEach(async () => {
// 每个测试后:清理测试数据
await deleteUser(userId);
});
test('用户应该有 name 属性', () => {
// 这里 userId 已经初始化好了
expect(createUser({ name: '小明' })).toHaveProperty('name');
});
beforeEach:每个it执行前跑一次afterEach:每个it执行后跑一次- 适合需要初始化/清理的场景,比如数据库操作

2.6 Supertest 是什么?
Supertest 是用来测试 HTTP 接口的库,不用真的起服务器,就能模拟发送请求、验证响应。
生活类比:Supertest 像一个「快递测试员」——不用真的寄包裹,模拟一下就知道包裹能不能按时送到、会不会摔坏。
最简例子:测试一个 Express 接口
先装依赖:
npm install --save-dev supertest
写一个简单的 Express app:
// app.js
const express = require('express');
const app = express();
app.get('/hello', (req, res) => {
res.json({ message: 'Hello, World!' });
});
app.get('/users/:id', (req, res) => {
const user = { id: req.params.id, name: '张三' };
res.json(user);
});
module.exports = app;
写测试:
// app.test.js
const request = require('supertest');
const app = require('./app');
test('GET /hello 应该返回 Hello, World!', async () => {
const res = await request(app)
.get('/hello')
.expect(200)
.expect('Content-Type', /json/);
expect(res.body).toEqual({ message: 'Hello, World!' });
});
test('GET /users/123 应该返回 id=123 的用户', async () => {
const res = await request(app)
.get('/users/123')
.expect(200);
expect(res.body.id).toBe('123');
expect(res.body.name).toBe('张三');
});
-
request(app):创建一个针对 app 的请求对象 -
.get('/hello'):发送 GET 请求 .expect(200):断言状态码是 200.expect('Content-Type', /json/):断言响应头res.body:响应体(自动解析 JSON)
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):测试一个计算器 —— 跟着抄就能跑
目标:写一个计算器模块,测试它的加减乘除功能。
目录结构:
calculator/
├── calculator.js
└── calculator.test.js
calculator.js:
// calculator.js
const calculator = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => {
if (b === 0) throw new Error('除数不能为0');
return a / b;
}
};
module.exports = calculator;
calculator.test.js:
// calculator.test.js
const calc = require('./calculator');
describe('计算器', () => {
describe('加法', () => {
it('1 + 2 = 3', () => {
expect(calc.add(1, 2)).toBe(3);
});
it('-1 + 1 = 0', () => {
expect(calc.add(-1, 1)).toBe(0);
});
});
describe('除法', () => {
it('10 / 2 = 5', () => {
expect(calc.divide(10, 2)).toBe(5);
});
it('除以 0 应该报错', () => {
expect(() => calc.divide(10, 0)).toThrow('除数不能为0');
});
});
});
运行:
npx jest calculator.test.js
预期输出:
PASS calculator.test.js
计算器
加法
✓ 1 + 2 = 3
✓ -1 + 1 = 0
除法
✓ 10 / 2 = 5
✓ 除以 0 应该报错
项目 2(15 分钟):测试一个待办清单 API —— 从 JSON 读数据
目标:写一个待办清单的 RESTful API,用 Supertest 测试 CRUD 操作。
app.js(完整的 Express API):
// app.js
const express = require('express');
const app = express();
app.use(express.json());
// 模拟数据库(内存)
let todos = [
{ id: 1, title: '写周报', done: false, priority: '高' },
{ id: 2, title: '买牛奶', done: false, priority: '低' }
];
let nextId = 3;
// GET /todos - 列出所有
app.get('/todos', (req, res) => {
res.json(todos);
});
// POST /todos - 添加
app.post('/todos', (req, res) => {
const { title, priority = '中' } = req.body;
const todo = { id: nextId++, title, done: false, priority };
todos.push(todo);
res.status(201).json(todo);
});
// PATCH /todos/:id - 更新状态
app.patch('/todos/:id', (req, res) => {
const id = parseInt(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) return res.status(404).json({ error: '未找到' });
if (typeof req.body.done === 'boolean') todo.done = req.body.done;
if (req.body.title) todo.title = req.body.title;
res.json(todo);
});
// DELETE /todos/:id - 删除
app.delete('/todos/:id', (req, res) => {
const id = parseInt(req.params.id);
const index = todos.findIndex(t => t.id === id);
if (index === -1) return res.status(404).json({ error: '未找到' });
todos.splice(index, 1);
res.status(204).send();
});
module.exports = app;
app.test.js:
// app.test.js
const request = require('supertest');
const app = require('./app');
describe('待办清单 API', () => {
describe('GET /todos', () => {
it('应该返回待办列表', async () => {
const res = await request(app).get('/todos');
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBeGreaterThan(0);
});
});
describe('POST /todos', () => {
it('应该创建新待办', async () => {
const newTodo = { title: '测试任务', priority: '高' };
const res = await request(app)
.post('/todos')
.send(newTodo)
.expect(201);
expect(res.body).toMatchObject({
title: '测试任务',
done: false,
priority: '高'
});
expect(res.body.id).toBeDefined();
});
});
describe('PATCH /todos/:id', () => {
it('应该标记待办为已完成', async () => {
// 先创建一个
const createRes = await request(app)
.post('/todos')
.send({ title: '待标记' });
const id = createRes.body.id;
// 再标记为完成
const res = await request(app)
.patch(`/todos/${id}`)
.send({ done: true })
.expect(200);
expect(res.body.done).toBe(true);
});
});
describe('DELETE /todos/:id', () => {
it('应该删除待办', async () => {
// 先创建一个
const createRes = await request(app)
.post('/todos')
.send({ title: '待删除' });
const id = createRes.body.id;
// 再删除
await request(app)
.delete(`/todos/${id}`)
.expect(204);
// 确认删除成功
await request(app)
.get(`/todos/${id}`)
.expect(404);
});
});
});
运行:
npx jest app.test.js
预期输出:
PASS app.test.js
待办清单 API
GET /todos
✓ 应该返回待办列表
POST /todos
✓ 应该创建新待办
PATCH /todos/:id
✓ 应该标记待办为已完成
DELETE /todos/:id
✓ 应该删除待办
项目 3(15 分钟):用 mock 隔离外部依赖 —— 组合前两个项目的能力
场景:你的代码里调用了第三方 API(比如获取天气),测试时不想真的发网络请求,怎么办?用 mock 模拟!
目标:写一个「根据城市获取天气」的服务,测试时 mock 掉 HTTP 请求。
weatherService.js:
// weatherService.js
const https = require('https');
function getWeather(city) {
return new Promise((resolve, reject) => {
// 模拟调用第三方 API(实际项目里用 axios 或 node-fetch)
const options = {
hostname: 'api.example.com',
path: `/weather?city=${encodeURIComponent(city)}`,
method: 'GET'
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.end();
});
}
module.exports = { getWeather };
weatherService.test.js(用 Jest mock):
// weatherService.test.js
const { getWeather } = require('./weatherService');
// 手动 mock 整个模块
jest.mock('./weatherService', () => ({
getWeather: jest.fn()
}));
describe('天气服务', () => {
beforeEach(() => {
// 每个测试前清空 mock 的调用记录
jest.clearAllMocks();
});
it('应该返回北京的天气', async () => {
// mock 返回值
getWeather.mockResolvedValue({
city: '北京',
temperature: 22,
weather: '晴'
});
const result = await getWeather('北京');
expect(result.city).toBe('北京');
expect(result.temperature).toBe(22);
});
it('应该处理 API 错误', async () => {
getWeather.mockRejectedValue(new Error('网络错误'));
await expect(getWeather('北京')).rejects.toThrow('网络错误');
});
});
运行:
npx jest weatherService.test.js
预期输出:
PASS weatherService.test.js
天气服务
✓ 应该返回北京的天气
✓ 应该处理 API 错误
核心理解:
- jest.mock() 把指定的模块替换成 mock 版本
- mockResolvedValue() 设置异步返回值
- mockRejectedValue() 设置抛异常
- 这样测试不依赖真实网络,又快又稳
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:异步测试忘记加 async/await
❌ 错误示例:
// 测试异步函数,没加 async/await —— 测试会直接通过(假阳性)
test('异步测试错误写法', () => {
fetchData().then(data => {
expect(data).toBe('success'); // 这个断言永远不会失败,因为没 await
});
});
✅ 正确示例:
// 正确写法:async + await
test('异步测试正确写法', async () => {
const data = await fetchData();
expect(data).toBe('success');
});
坑 2:测试里改了全局状态,没还原
❌ 错误示例:
let count = 0;
test('第一次测试', () => {
count = 10;
expect(count).toBe(10); // 通过
});
test('第二次测试', () => {
expect(count).toBe(0); // 失败!因为上次改了全局 count
});
✅ 正确示例(用 beforeEach 初始化):
let count = 0;
beforeEach(() => {
count = 0; // 每次测试前还原
});
test('第一次测试', () => {
count = 10;
expect(count).toBe(10);
});
test('第二次测试', () => {
expect(count).toBe(0); // 通过!
});
坑 3:mock 了没清理,影响后续测试
❌ 错误示例:
test('测试 A', () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
// console.log 被 mock 了
});
test('测试 B', () => {
console.log('hello'); // 这行不会输出,因为 mock 没清理!
});
✅ 正确示例:
test('测试 A', () => {
const logMock = jest.spyOn(console, 'log').mockImplementation(() => {});
// 测试逻辑...
logMock.mockRestore(); // 手动还原
});
test('测试 B', () => {
console.log('hello'); // 正常输出!
});
或者用 afterEach 统一清理:
afterEach(() => {
jest.restoreAllMocks(); // 清理所有 spy/mock
});
坑 4:期望值写错,导致测试永远通过
❌ 错误示例:
// 把 toBe 错写成了 toEqual 的反向
test('错误的断言', () => {
expect(1).not.toBe(1); // 永远失败!1 当然等于 1
});
✅ 正确示例:
test('正确的断言', () => {
expect(1).toBe(1); // 通过
expect([1, 2]).toEqual([1, 2]); // 数组用 toEqual
});
坑 5:用 Supertest 测试时,忘记处理异步
❌ 错误示例:
test('忘记 await', () => {
request(app).get('/todos').expect(200); // 没 await,不会等待响应
// 测试会直接通过,但实际请求可能失败了
});
✅ 正确示例:
test('正确用法', async () => {
await request(app).get('/todos').expect(200);
});
性能小贴士:用 Jest --coverage 看覆盖率
想知道你的代码有多少被测到了?跑:
npx jest --coverage
输出示例:
PASS calculator.test.js
计算器
加法
✓ 1 + 2 = 3
除法
✓ 10 / 2 = 5
File | % Stmts | % Branch | % Funcs | % Lines |
----------|----------|-----------|----------|----------|
calculator.js | 85.71 | 100 | 100 | 85.71 |
- Stmts:语句覆盖率,85% 说明有 15% 的代码没跑到
- Branch:分支覆盖率,比如 if/else 只测了一个分支
- Funcs:函数覆盖率
- Lines:行覆盖率
覆盖率不是越高越好,一般 80% 是及格线。
调试技巧:用 --verbose 看详细输出
npx jest --verbose
输出:
PASS calculator.test.js
计算器
加法
✓ 1 + 2 = 3 (4ms)
✓ -1 + 1 = 0 (2ms)
每个测试用例耗时都显示出来,方便定位慢的测试。
✏️ 练习题 + 作业题(共 7 分钟)
练习题(5 道,10 分钟内完成)
练习 1(2 分钟):改一个变量
- 输入:把项目 1 的 add 测试改成测试 multiply,期望 3 * 4 = 12
- 预期输出:测试通过
- 提示:把 calc.add(1, 2) 改成 calc.multiply(3, 4),toBe(3) 改成 toBe(12)
练习 2(2 分钟):加一个断言
- 输入:在项目 1 的除法测试里,加一个断言:验证 8 / 4 = 2
- 预期输出:两个测试都通过
- 提示:用 it() 再加一个用例,或者在现有用例里加一行 expect(calc.divide(8, 4)).toBe(2);
练习 3(3 分钟):测试一个新 API
- 输入:在项目 2 的 app 上加一个 GET /stats 接口,返回 { total: todos.length },然后写测试验证
- 预期输出:测试通过,返回 { total: 2 }(因为初始有两个待办)
- 提示:先在 app.js 加路由,再在 test 文件里 await request(app).get('/stats').expect(200).expect({ total: 2 })
练习 4(3 分钟):串起来
- 输入:给项目 2 的 POST /todos 加一个测试:创建后,再 GET /todos,验证列表长度增加了 1
- 预期输出:测试通过
- 提示:先记录初始长度 const before = (await request(app).get('/todos')).body.length;,再 POST,再验证 body.length === before + 1
练习 5(挑战题,5 分钟):修报错
- 输入:运行以下代码,报错 Cannot find module './math'
// math.test.js
const { add } = require('./math');
test('1 + 1 = 2', () => {
expect(add(1, 1)).toBe(2);
});
- 预期输出:测试通过
- 提示:检查
math.js是否和math.test.js在同一目录,且文件名拼写一致
作业题(1 道大题,30 分钟~2 小时)
作业:做一个「学生成绩管理系统」的 API 测试
- 需求描述:写一个学生成绩管理的 RESTful API,给它写完整的测试
- 功能点:
1.GET /students- 返回学生列表(id, name, score)
2.POST /students- 添加学生(name, score)
3.GET /students/:id- 获取单个学生
4.GET /students/avg- 返回平均分
5.DELETE /students/:id- 删除学生 - 加分项:
1. 用 mock 模拟一个外部验证服务(比如验证学生姓名是否合法)
2. 测试边界情况(如删除不存在的学生、成绩超出 0-100 范围)
3. 生成覆盖率报告,贴到评论区 - 验收标准:能跑起来 + 5 个功能点都有测试 + 测试全部通过
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源(5 分钟)
一句话总结:本文你学会了——
1. 用 test() + expect() 写单元测试,给函数写「自动体检单」
2. 用 Supertest 测试 HTTP API,不用起服务器也能验证接口
3. 用 jest.mock() 隔离外部依赖,测试跑得快又稳
延伸学习:
- Jest 官方文档 —— 最权威,有中文版
- Supertest GitHub —— 看看作者的示例
- 《Node.js 实战》第 8 章 —— 专门讲测试,讲得很细
互动钩子:你在项目里用过 Jest 或 Supertest 吗?遇到过什么坑?评论区聊聊,下一章我们要讲部署(Docker + PM2 + Nginx),学完这章你就能把带测试的代码优雅地部署上线了!
全文完。跑测试遇到报错,把截图和代码贴评论区,我帮你盯。

评论(0)