CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
单元测试
这一课你将能做到
用 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');
});
});
vi.mock 替换模块、mockResolvedValue/mockRejectedValue 指定返回、toHaveBeenCalledWith 验证调用——mock 的本质是:把「不可控的外部世界」换成「你说了算的剧本」 ,让单元测试回到毫秒级。
步骤 5:JUnit 5 与 pytest 对照 同样的计价测试,Java 与 Python 长这样:
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");
}
}
# 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():把「取当前时间」作为依赖传入或用库的时钟替身(Vitest vi.useFakeTimers、Java Clock 注入),测试里把时间拨到任意点。这同时倒逼出更好的设计。
自测三问
给 discountPrice 新增规则「折扣价不低于 1 元」,用 TDD 流程走一遍:先写出会失败的测试。
getUserLabel 的三个测试如果去掉 beforeEach(() => vi.clearAllMocks()),哪个用例可能被前一个污染?为什么?
你的项目里「金额计算模块」和「纯 CRUD 接口」分别适合放多少单测、多少集成测试?说理由。
小结一句话 单元测试 = 给核心逻辑配 FIRST 级的自动化体检:测行为不测实现、mock 只留在边界、TDD 红绿循环让需求先变成可执行的规格。
延伸