第44章 测试:Jest 入门
上一章我们折腾了 Web Worker,学会了在后台线程里跑耗时任务,再也不用担心页面卡死了。但不知道你有没有这种感觉:代码越写越多,改一处其他地方突然就崩了,心里慌得一批。
这一章我们要解决一个问题:怎么让代码「自己证明自己是对的」?
🎯 开场:为什么你需要测试?
想象一下这个场景——
你写了一个计算工资的程序,跑了 3 年都没问题。结果某天领导说「把年终奖的逻辑改一下」。你改完测试,咦,工资算对了。但你不知道的是,报销模块悄悄坏了——因为报销的计算逻辑复用了一部分工资的代码。
这就是没有测试的日常:改代码像拆盲盒,不知道哪个功能会中枪。
痛点 1:手动测试太累,每次改代码要点点点半天
痛点 2:不知道哪里坏了,等用户报错才知道
学完这章,你能用 Jest 写自动化测试,改代码不再慌,交给程序帮你验。
🧱 基础:Jest 核心概念
什么是 Jest?
说白了,Jest 就是一个「自动跑测试的程序」。
生活类比:就像食堂的自动\n\n
\n\n
\n\n检测仪,你把饭菜放进去,它自动告诉你「这道菜盐放多了」「那个菜温度不够」。你不用一道道人工尝,机器帮你搞定。
安装 Jest
先有项目才能装 Jest,我们用 npm 初始化一个:
mkdir my-project && cd my-project
npm init -y
npm install --save-dev jest
安装完成后,打开 package.json,找到 scripts 那一段,改成:
"scripts": {
"test": "jest"
}
以后运行测试,只需要在终端敲:
npm test
第一个测试:认识 describe / it / expect
假设你写了一个加法函数 add.js:
// add.js
function add(a, b) {
return a + b;
}
module.exports = add;
现在你要测试它,创建 add.test.js:
// add.test.js
const add = require('./add');
// describe 就像一个文件夹,把相关测试归类
describe('加法函数', () => {
// it 里面写一个具体的测试用例
it('1 + 2 应该等于 3', () => {
// expect 是「我相信结果会是」
expect(add(1, 2)).toBe(3);
});
it('0 + 0 应该等于 0', () => {
expect(add(0, 0)).toBe(0);
});
});
运行 npm test,输出:
PASS ./add.test.js
加法函数
✓ 1 + 2 应该等于 3
✓ 0 + 0 应该等于 0
解释:toBe(3) 是匹配器,意思是「应该等于 3」。Jest 还有很多匹配器:
expect(value).toBe(3) // 等于
expect(value).toEqual(3) // 深度相等(对象用这个)
expect(value).toBeNull() // 是 null
expect(value).toBeTruthy() // 是真值
expect(value).toBeFalsy() // 是假值
expect(value).toBeGreaterThan(3) // 大于
expect(value).toContain('a') // 包含
Mock:假装调用
有时候测试需要「假装」调用某个函数,比如你不想真的发网络请求。
生活类比:就像拍电影不需要真砸玻璃,用糖化玻璃假装一下就行。
// fetchUser.js
function fetchUser(id, callback) {
// 真实代码会调 API,这里先省略
}
// mock 示例:假装调用
test('假装调用回调', () => {
const mockCallback = jest.fn(); // 创建一个假的回调函数
fetchUser(1, mockCallback);
// 验证它被调用了
expect(mockCallback).toHaveBeenCalled();
// 验证它被调用了一次
expect(mockCallback).toHaveBeenCalledTimes(1);
});
有时候你还需要「假装有返回值」:
test('假装返回用户数据', () => {
const mockFetch = jest.fn().mockReturnValue({ name: '张三', age: 25 });
const result = mockFetch(1);
expect(result).toEqual({ name: '张三', age: 25 });
expect(mockFetch).toHaveBeenCalledWith(1); // 验证传参是 1
});
覆盖率:你的代码被测了多少?
运行带覆盖率的测试:
npm test -- --coverage
输出会显示「哪些文件被测了」「每行代码有没有被执行到」:
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
add.js | 100 | 100 | 100 | 100 |
100% 不是说代码没问题,而是「每一行都被跑过了」。覆盖率越高,漏网之鱼越少。
🔥 实战:3 个递进小项目
项目 1:测试一个工资计算器(5 分钟)
目标:学会用 Jest 测试纯函数
创建 salary.js 和 salary.test.js:
// salary.js
function calculateSalary(base, bonus, taxRate = 0.1) {
const gross = base + bonus;
const tax = gross * taxRate;
return gross - tax;
}
module.exports = { calculateSalary };
// salary.test.js
const { calculateSalary } = require('./salary');
describe('工资计算器', () => {
it('基本工资 10000 + 奖金 2000,税后应该是 10800', () => {
expect(calculateSalary(10000, 2000)).toBe(10800);
});
it('没有奖金时,10000 基本工资税后应该是 9000', () => {
expect(calculateSalary(10000, 0)).toBe(9000);
});
it('自定义税率 20% 时,10000 + 2000 应该税后 9600', () => {
expect(calculateSalary(10000, 2000, 0.2)).toBe(9600);
});
});
预期输出:
PASS ./salary.test.js
工资计算器
✓ 基本工资 10000 + 奖金 2000,税后应该是 10800
✓ 没有奖金时,10000 基本工资税后应该是 9000
✓ 自定义税率 20% 时,10000 + 2000 应该税后 9600
一句话解释:用 toBe() 匹配数字,toEqual() 用于对象/数组的深度比较。
项目 2:测试一个数据处理函数(15 分钟)
目标:从 CSV/JSON 读数据,测试过滤和统计逻辑
假设你做了一个「筛选高价值用户」的功能:
// analytics.js
function findHighValueUsers(users, minSpending) {
return users.filter(user => user.totalSpending >= minSpending);
}
function calculateAverageAge(users) {
if (users.length === 0) return 0;
const sum = users.reduce((acc, user) => acc + user.age, 0);
return sum / users.length;
}
module.exports = { findHighValueUsers, calculateAverageAge };
// analytics.test.js
const { findHighValueUsers, calculateAverageAge } = require('./analytics');
describe('用户分析', () => {
// 测试数据
const mockUsers = [
{ id: 1, name: '张三', totalSpending: 5000, age: 25 },
{ id: 2, name: '李四', totalSpending: 15000, age: 30 },
{ id: 3, name: '王五', totalSpending: 8000, age: 28 },
{ id: 4, name: '赵六', totalSpending: 20000, age: 35 },
];
it('筛选消费满 10000 的用户,应该得到李四和赵六', () => {
const result = findHighValueUsers(mockUsers, 10000);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('李四');
expect(result[1].name).toBe('赵六');
});
it('筛选消费满 50000 的用户,应该得到空数组', () => {
const result = findHighValueUsers(mockUsers, 50000);
expect(result).toHaveLength(0);
expect(result).toEqual([]);
});
it('计算平均年龄,4 个用户平均应该是 29.5', () => {
expect(calculateAverageAge(mockUsers)).toBe(29.5);
});
it('空数组的平均年龄应该是 0', () => {
expect(calculateAge([])).toBe(0);
});
});
预期输出:
PASS ./analytics.test.js
用户分析
✓ 筛选消费满 10000 的用户,应该得到李四和赵六
✓ 筛选消费满 50000 的用户,应该得到空数组
✓ 计算平均年龄,4 个用户平均应该是 29.5
✓ 空数组的平均年龄应该是 0
一句话解释:测试数据用 const 定义在 describe 里面,所有测试共享,修改只在一处。
项目 3:待办清单过滤工具(15 分钟)
目标:组合前两个项目,做一个有点真实用的小工具
// todo.js
class TodoList {
constructor() {
this.items = [];
}
add(title, priority = 'medium') {
this.items.push({
id: Date.now(),
title,
priority,
completed: false,
createdAt: new Date().toISOString()
});
}
complete(id) {
const item = this.items.find(item => item.id === id);
if (item) item.completed = true;
return item;
}
filterByPriority(priority) {
return this.items.filter(item => item.priority === priority);
}
getPending() {
return this.items.filter(item => !item.completed);
}
getStats() {
return {
total: this.items.length,
completed: this.items.filter(i => i.completed).length,
pending: this.items.filter(i => !i.completed).length
};
}
}
module.exports = { TodoList };
// todo.test.js
const { TodoList } = require('./todo');
describe('待办清单', () => {
let todo;
// 每个测试前都重新创建实例,保证独立性
beforeEach(() => {
todo = new TodoList();
});
it('添加一个待办事项后,列表长度应该是 1', () => {
todo.add('买牛奶');
expect(todo.items).toHaveLength(1);
});
it('添加待办时指定优先级,应该能按优先级筛选', () => {
todo.add('买牛奶', 'high');
todo.add('写作业', 'medium');
todo.add('玩游戏', 'low');
const highPriority = todo.filterByPriority('high');
expect(highPriority).toHaveLength(1);
expect(highPriority[0].title).toBe('买牛奶');
});
it('完成一个待办后,它应该出现在已完成列表', () => {
todo.add('买牛奶');
const id = todo.items[0].id;
todo.complete(id);
const pending = todo.getPending();
expect(pending).toHaveLength(0);
});
it('统计数据应该正确反映完成状态', () => {
todo.add('买牛奶');
todo.add('写作业');
todo.complete(todo.items[0].id);
const stats = todo.getStats();
expect(stats.total).toBe(2);
expect(stats.completed).toBe(1);
expect(stats.pending).toBe(1);
});
it('新清单的统计数据应该是 0', () => {
const stats = todo.getStats();
expect(stats.total).toBe(0);
expect(stats.completed).toBe(0);
expect(stats.pending).toBe(0);
});
});
预期输出:
PASS ./todo.test.js
待办清单
✓ 添加一个待办事项后,列表长度应该是 1
✓ 添加待办时指定优先级,应该能按优先级筛选
✓ 完成一个待办后,它应该出现在已完成列表
✓ 统计数据应该正确反映完成状态
✓ 新清单的统计数据应该是 0
一句话解释:beforeEach 在每个测试前运行,保证测试之间互不干扰。
💪 进阶:常见坑 + 调试技巧
坑 1:对象比较用 toBe 还是 toEqual?
// ❌ 错误:toBe 比较的是引用,new Object() !== new Object()
expect({ name: '张三' }).toBe({ name: '张三' });
// ✅ 正确:toEqual 深度比较内容
expect({ name: '张三' }).toEqual({ name: '张三' });
坑 2:异步测试忘用 Promise 或 async/await
// ❌ 错误:测试不会等异步完成
it('应该返回用户数据', () => {
let result;
fetchUser(1).then(data => { result = data; });
expect(result).toEqual({ name: '张三' }); // 此时 result 还是 undefined
});
// ✅ 正确:返回 Promise
it('应该返回用户数据', () => {
return fetchUser(1).then(data => {
expect(data).toEqual({ name: '张三' });
});
});
// ✅ 或者用 async/await 更简洁
it('应该返回用户数据', async () => {
const data = await fetchUser(1);
expect(data).toEqual({ name: '张三' });
});
坑 3:Mock 函数忘记重置
// ❌ 错误:上一个测试的调用记录还在
test('第一次调用', () => {
const mock = jest.fn();
someFunction(mock);
expect(mock).toHaveBeenCalledTimes(1);
});
test('第二次调用', () => {
const mock = jest.fn(); // 这里声明了新的 mock
// 但是 someFunction 用的可能还是旧的...
});
// ✅ 正确:用 beforeEach 重置
let mock;
beforeEach(() => {
mock = jest.fn();
});
坑 4:测试里不要写太复杂的逻辑
// ❌ 错误:测试本身有 bug,查起来更麻烦
it('复杂逻辑', () => {
const result = someFunc(input);
const expected = input.map(x => x * 2).filter(x => x > 10).reduce((a, b) => a + b);
expect(result).toBe(expected);
});
// ✅ 正确:把期望值直接写出来
it('应该过滤大于 10 的值并求和', () => {
expect(someFunc([1, 5, 10, 15])).toBe(25); // 15 > 10, 15 + 10 = 25... 不对,
// 实际期望是 (10*2) + (15*2) = 50... 等等,让我重新算
// 算了直接用简单例子:someFunc([5, 15]) 期望返回 30 (15*2)
expect(someFunc([5, 15])).toBe(30);
});
坑 5:测试文件名必须是 .test.js
// ❌ 错误:Jest 不会运行这个文件
it('测试', () => { ... }); // 文件名是 example.spec.js 或 example.js
// ✅ 正确:确保是 xxx.test.js
// add.test.js ✓
// calculator.spec.js ✓
调试技巧:看看到底发生了什么
在测试里加 console.log,Jest 会自动显示输出:
it('调试用', () => {
const result = someFunction(input);
console.log('实际结果:', result);
console.log('期望值:', expected);
expect(result).toEqual(expected);
});
运行 npm test -- --verbose 可以看到每个测试的名字,方便定位。
✏️ 练习题
练习 1(2 分钟):改个数字
- 输入:把项目 1 中税率从 0.1 改成 0.15
- 预期输出:测试通过,税后工资变化
- 提示:只改一行代码
练习 2(3 分钟):加一个判断
- 输入:在项目 1 中添加测试「奖金为负数时应该返回 base」
- 预期输出:测试通过
- 提示:expect(calculateSalary(10000, -500)).toBe(10000);
练习 3(5 分钟):新数据
- 输入:用项目 2 的方法处理 [{ name: 'A', spending: 3000, age: 20 }, { name: 'B', spending: 12000, age: 40 }]
- 预期输出:筛选出 B,平均年龄 30
- 提示:直接复制测试模板,改数据
练习 4(10 分钟):串起来
- 输入:用项目 2 的 findHighValueUsers 过滤后的结果,传入项目 3 的 TodoList 的 add 方法
- 预期输出:能正常添加并统计
- 提示:先过滤出高价值用户,再遍历添加为待办
练习 5(5 分钟):报错分析
- 输入:以下代码报错 Expected: 3, Received: "3"
- 预期输出:说出原因并修复
- 提示:类型问题,toBe 比较严格
作业:做一个「个人预算统计工具」
- 需求描述:帮小明月光族做预算管理,记录收入支出,计算结余
- 功能点:
1. 记录收入和支出(金额、类别、日期)
2. 按月统计收支情况
3. 找出支出最高的类别 - 加分项:
1. 支持从 JSON 文件读取数据
2. 生成简单的文字报告 - 验收标准:
- 至少有 5 个测试用例,覆盖核心逻辑
- 测试能通过
- 代码有适当注释
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结
这一章学了 3 件事:
1. Jest 是自动化测试框架,让代码自己证明自己没问题
2. describe/it/expect 是核心三件套,组织测试、编写断言
3. Mock 和覆盖率 帮助测试复杂场景和评估测试质量
延伸资源:
- Jest 官方文档(中文版,例子很全)
- Jest 交互式教程(免费,动手敲代码)
- 《JavaScript 测试入门》(图灵电子书,适合小白)
互动钩子:你在工作中有没有「因为没写测试,上线后翻车」的经历?评论区聊聊,老粉优先回复!
下一章我们要做一个大项目——仿 V2EX 论坛前端,到时候你会发现,这一章学的测试能让项目质量上一个台阶,不信走着瞧。

评论(0)