大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
单元测试
这一课你将能做到
- 用 Vitest 为业务函数写出结构清晰(AAA)、覆盖边界条件的单元测试并跑通
- 用 mock 隔离外部依赖(数据库/HTTP),让测试不依赖任何外部环境就能跑
- 画出测试金字塔,说出单元测试应该测什么、不应该测什么,以及覆盖率数字的正确用法
为什么要学
改一行不敢发布、重构全靠人肉回归、bug 修 A 出 B——这些痛的根源都是没有测试网。单元测试是这张网的最密一层:它跑得快(毫秒级)、定位准(失败即具体函数)、写得勤(每次改动随手补)。
人话:单元测试就是给代码配的「体检机」。你把函数的输入喂进去,断言输出符合预期;以后任何人改了这段代码,体检机秒级报警。面试时说「我为核心逻辑写过单测、TDD 跑过红绿循环」,和说「我没写过测试」,在面试官眼里是两种工程师。
本课主线用 Node 的 Vitest(对 TS 友好、零配置、和第 7-9 课的项目无缝衔接),JUnit 5 与 pytest 给出对照写法——概念完全一致,换语言只是换断言 API。
先建立图像
测试金字塔与好测试的 FIRST 标准:
╱ E2E ╲ 少而慢:整条链路,分钟级
╱ 集成测试 ╲ 适量:模块拼起来的"缝",秒级
╱ 单元测试 ╲ 大量:单个函数/类,毫秒级,占 70%+
╱──────────────╲
好的单元测试 = FIRST
Fast 快(毫秒级,敢天天跑)
Isolated 独立(不依赖别的测试、不依赖网络/数据库)
Repeatable 可重复(同一环境每次结果一致)
Self-validating 自校验(通过/失败自动判定,不用人看输出)
Timely 及时(随代码一起写,不是事后补)
跟着做
步骤 1:项目初始化,跑通第一个测试
mkdir bebg-test-unit && cd bebg-test-unit && npm init -y
npm install -D vitest
package.json 里补两处(type: module 让项目用 ESM):
{
"type": "module",
"scripts": { "test": "vitest run", "test:watch": "vitest" }
}
写一个真实的业务函数(折扣计价,边界多、适合当主角):
// src/price.js
export function discountPrice(price, vipLevel) {
if (!Number.isFinite(price) || price < 0) {
throw new Error('价格必须是非负数字');
}
const level = vipLevel ?? 0;
if (level === 1) return round2(price * 0.95);
if (level === 2) return round2(price * 0.90);
return round2(price);
}
function round2(n) {
return Math.round(n * 100) / 100; // 金额保留两位,避免浮点误差
}
第一个测试文件(注意目录约定:tests/ 或与源码同目录 *.test.js 都行):
// tests/price.test.js
import { describe, it, expect } from 'vitest';
import { discountPrice } from '../src/price.js';
describe('discountPrice 折扣计价', () => {
it('无会员等级不打折', () => {
expect(discountPrice(100, 0)).toBe(100);
});
it('VIP1 打 95 折,VIP2 打 9 折', () => {
expect(discountPrice(100, 1)).toBe(95);
expect(discountPrice(100, 2)).toBe(90);
});
it('结果保留两位小数(33.33 * 0.95 这类浮点坑)', () => {
expect(discountPrice(33.33, 2)).toBe(30); // 29.997 → 30
expect(discountPrice(10.1, 1)).toBe(9.6); // 9.595 → 9.6(不是 9.59)
});
it('负数价格抛出异常', () => {
expect(() => discountPrice(-1, 0)).toThrow('价格必须是非负数字');
});
it('vipLevel 缺省视为 0', () => {
expect(discountPrice(88)).toBe(88);
});
});
npm test
# ✓ tests/price.test.js (5 tests) — 全绿
体会一下结构:describe 圈住一个被测单元,每个 it 是一条可读的规格说明——把「it 的标题连起来读」,就是这个函数的需求文档。
步骤 2:AAA 结构——每个测试的三段式
把上面任意一个测试放大看:
it('VIP2 打 9 折', () => {
// Arrange 准备:构造输入
const price = 100;
const vipLevel = 2;
// Act 执行:只调用一次被测函数
const result = discountPrice(price, vipLevel);
// Assert 断言:验证结果
expect(result).toBe(90);
});
一条测试只验证一种行为;断言尽量只有一个主断言。测试写得啰嗦不要紧,读得懂才是第一目标——三个月后失败报警时,你要靠它 10 秒定位。
步骤 3:TDD 走一轮红-绿-重构
需求新增:满 300 再打 88 折(可与 VIP 折叠,叠加后仍保留两位小数)。按 TDD 先写会失败的测试:
// 追加到 tests/price.test.js
it('满 300 叠加满减折扣(VIP2 + 满300 = 90 * 88% = 79.2)', () => {
expect(discountPrice(300, 2)).toBe(79.2); // 现在 RUNNING 会失败 —— 红
});
npm test
# ✗ 1 failed —— 红灯,确认测试真的能测出新功能缺失
实现最小代码让测试通过(src/price.js 修改):
export function discountPrice(price, vipLevel) {
if (!Number.isFinite(price) || price < 0) {
throw new Error('价格必须是非负数字');
}
let result = price;
if (vipLevel === 1) result *= 0.95;
if (vipLevel === 2) result *= 0.90;
if (result >= 300) result *= 0.88; // 新增
return round2(result); // 绿灯
}
最后重构:变量改名、拆函数,跑测试保持绿。红-绿-重构的节奏让每一步都有网兜着。
步骤 4:mock——隔离外部依赖
问题来了:函数依赖数据库/HTTP 怎么测?答案不是连真库,而是mock 替身。写一个典型场景——用户服务调外部接口:
// src/api.js —— 真实的 HTTP 依赖(测试时会被替换)
export async function fetchFromApi(path) {
const res = await fetch('https://api.example.com' + path);
if (!res.ok) throw new Error('请求失败: ' + res.status);
return res.json();
}
// src/userService.js —— 被测对象
import { fetchFromApi } from './api.js';
export async function getUserLabel(id) {
const user = await fetchFromApi(`/users/${id}`);
if (!user.name) throw new Error('用户资料不完整');
return `${user.name}<${user.email ?? 'no-email'}>`;
}
// tests/userService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 把整个 api 模块换成替身——测试不碰网络
vi.mock('../src/api.js', () => ({
fetchFromApi: vi.fn(),
}));
import { fetchFromApi } from '../src/api.js';
import { getUserLabel } from '../src/userService.js';
describe('getUserLabel', () => {
beforeEach(() => vi.clearAllMocks());
it('正常拼出 label', async () => {
fetchFromApi.mockResolvedValue({ name: 'alice', email: 'a@x.com' });
await expect(getUserLabel(1)).resolves.toBe('alice<a@x.com>');
expect(fetchFromApi).toHaveBeenCalledWith('/users/1'); // 顺带验证交互
});
it('没有邮箱时用占位符', async () => {
fetchFromApi.mockResolvedValue({ name: 'bob' });
await expect(getUserLabel(2)).resolves.toBe('bob<no-email>');
});
it('上游 500 时错误向上抛', async () => {
fetchFromApi.mockRejectedValue(new Error('请求失败: 500'));
await expect(getUserLabel(3)).rejects.toThrow('请求失败: 500');
});
});
npm test # 全绿,全程零网络请求
vi.mock 替换模块、mockResolvedValue/mockRejectedValue 指定返回、toHaveBeenCalledWith 验证调用——mock 的本质是:把「不可控的外部世界」换成「你说了算的剧本」,让单元测试回到毫秒级。
步骤 5:JUnit 5 与 pytest 对照
同样的计价测试,Java 与 Python 长这样:
JUnit 5(Java):
class PriceTest {
@Test
@DisplayName("VIP2 打 9 折")
void vip2Discount() {
assertEquals(90.0, PriceService.discountPrice(100, 2), 0.001);
}
@Test
@DisplayName("负数价格抛异常")
void negativePrice() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> PriceService.discountPrice(-1, 0));
assertEquals("价格必须是非负数字", ex.getMessage());
}
// Mockito 模拟依赖,等价于 vi.mock
@Test
void fetchIsCalled() {
ApiClient api = mock(ApiClient.class);
when(api.fetch("/users/1")).thenReturn(new User("alice", "a@x.com"));
UserService service = new UserService(api);
assertEquals("alice<a@x.com>", service.getUserLabel(1));
verify(api).fetch("/users/1");
}
}
pytest(Python):
# test_price.py
import pytest
from price import discount_price
def test_vip2_discount():
assert discount_price(100, 2) == 90
def test_negative_price():
with pytest.raises(ValueError, match="价格必须是非负数字"):
discount_price(-1, 0)
# monkeypatch 模拟依赖,等价于 vi.mock
def test_get_user_label(monkeypatch):
import user_service
async def fake_fetch(path):
return {"name": "alice", "email": "a@x.com"}
monkeypatch.setattr(user_service, "fetch_from_api", fake_fetch)
assert user_service.get_user_label(1) == "alice<a@x.com>"
注意共性:命名表达行为、异常用专门断言、依赖用替身注入。语言换了,思维不变。
讲清楚要点
- 测行为,不测实现:断言「输入 100/等级 2 输出 90」,而不是断言「内部先乘了 0.9」。测实现的测试一重构就碎,沦为改代码的累赘——这是测试腐烂的第一大原因。
- 什么值得测:业务规则与边界(0、负数、极值、空、非法输入)、修过的 bug(写个回归测试钉死)、核心链路的分支。不值得测:getter/setter、框架自身行为、纯展示拼串。测试是投资,要有回报率。
- 覆盖率是仪表盘不是 KPI:80% 上下是常见的健康线,但关键分支没测的 90% 不如有价值断言的 60%。为凑数字给 getter 写测试是自我欺骗。Vitest 查看覆盖率:
npm install -D @vitest/coverage-v8后npx vitest run --coverage。 - mock 的克制原则:只 mock 你不拥有的边界(网络、时钟、随机、文件系统);不要 mock 被测对象自己,也不要 mock 它的内部方法——mock 太多说明模块拆分有问题,测的全是「剧本」而不是代码。
- 测试代码同样是代码:命名、去重(参数化)、入库标准(测试不过不许合并——CI 里强制跑,第 11 课接上)。
你可能会懵的地方
-
私有方法要不要测? 不直接测。私有方法是实现细节,通过公共接口覆盖它;如果公共接口怎么都触发不到那段私有逻辑,说明要么是死代码、要么类职责太大该拆了。
-
一个测试里能放多个断言吗? 验证同一行为的多个侧面可以(如结果值和类型);不同行为必须拆开——否则第一个断言挂了,后面的全不执行,你一次只能看到一个错。
-
测试之间可以共享数据吗? 只读常量可以,可变状态绝不行(模块级变量、数据库残留、文件)。每个测试自建自清(beforeEach/beforeAll + 隔离),执行顺序不影响结果——顺序依赖的测试会在 CI 并行时随机爆炸。
-
TDD 每个功能都要走吗? 现实是弹性执行:核心业务规则、复杂边界适合严格红绿循环;原型探索期先写代码补测试也可接受。但「补」要发生在合并之前,不是「以后再说」。
-
异步代码怎么测? 断言用
resolves/rejects(Vitest)、await assertThrowsAsync(pytest 有pytest-asyncio),JDK 用 CompletableFuture 断言。核心纪律只有一条:忘了 await 的测试永远绿——一个从不失败的测试比没有测试更危险,可以故意塞一个错误断言确认它会红。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 断言全部注释掉「临时跳过」 | 测试永远绿,形同虚设 | 用 it.skip 显式跳过并在 PR 里说明 |
| 测试读取顺序依赖(B 测试依赖 A 建的数据) | 并行执行随机挂 | 每个测试自建数据、自清理 |
| mock 被测对象内部方法 | 测的是剧本不是行为 | 只替换外部依赖的边界 |
| 一个 it 塞十种输入循环断言 | 失败时不知道哪个输入错 | 参数化(it.each / @ParameterizedTest / pytest.mark.parametrize) |
| 事后补测试对着实现抄 | 只验证了现状,覆盖不了应有的边界 | 按需求/边界设计用例,实现是黑盒 |
| 追求 100% 覆盖率 | 大量无效测试拖慢迭代 | 保住关键路径与边界,覆盖率做参考 |
卡点急救
vitest: command not found:装成了全局依赖或没装。npm install -D vitest(本地依赖),用npm test走 package.json 的 script。- 报
SyntaxError: Cannot use import statement outside a module:package.json缺"type": "module",或测试文件后缀应为.mjs。本项目模板记得补上。 vi.mock不生效:路径必须与被测文件导入的路径完全一致(含../与扩展名);vi.mock会被提升到文件顶部,不要放在 it 里面。- 测试意外全绿(测试失效):异步断言忘了
await/resolves。检验法:故意写一个必失败的断言,确认测试真的会红。 - watch 模式跑不停或跑太多:
npm run test(单次)与npm run test:watch(增量监听)区分开;CI 里只用单次模式。
面试常问
-
单元测试和集成测试的区别? 单测:最小单元(函数/类)+ 依赖全部替身 + 毫秒级 + 数量最多;集成:多个真实模块拼装(如路由+服务+真测试库)+ 秒级 + 数量适中。前者定位「逻辑对不对」,后者定位「拼起来通不通」。下一课专门展开。
-
为什么需要 mock?滥用有什么问题? 隔离不可控依赖(网络/DB/时钟),保证快、稳、可重复。滥用(mock 链太深、mock 被测对象内部)会让测试与实现强耦合、一改就碎——mock 只留在架构边界上。
-
说说 TDD 的流程与利弊。 红(写失败测试明确需求)→ 绿(最小实现通过)→ 重构(保持绿的前提下改善结构)。好处:需求先落地成可执行规格、回归网随写随有;成本:起步慢、对设计能力有要求。适合规则明确的业务逻辑,探索性代码可放宽。
-
测试覆盖率多少合适? 没有魔法数字:工程上 70-80% 是常见健康线,但更重要的是关键分支全覆盖 + 失败测试有清晰行为语义。回答要点出「覆盖率衡量的是执行过,不是验证过」。
-
如何测试一段依赖当前时间的逻辑(如优惠券过期)? 注入时钟而不是直接
new Date():把「取当前时间」作为依赖传入或用库的时钟替身(Vitestvi.useFakeTimers、JavaClock注入),测试里把时间拨到任意点。这同时倒逼出更好的设计。
自测三问
- 给
discountPrice新增规则「折扣价不低于 1 元」,用 TDD 流程走一遍:先写出会失败的测试。 getUserLabel的三个测试如果去掉beforeEach(() => vi.clearAllMocks()),哪个用例可能被前一个污染?为什么?- 你的项目里「金额计算模块」和「纯 CRUD 接口」分别适合放多少单测、多少集成测试?说理由。
小结一句话
单元测试 = 给核心逻辑配 FIRST 级的自动化体检:测行为不测实现、mock 只留在边界、TDD 红绿循环让需求先变成可执行的规格。
延伸
- Vitest 官方文档
- 《单元测试的艺术》——测试思维经典
- 下一课:集成与功能测试——模块拼起来的「缝」,得用真环境来验。