单元测试入门:代码质量的第一道防线
单元测试不是为了“追求覆盖率”,而是为了让改代码不再心虚。本文用可落地的例子讲清楚:测什么、不测什么、怎么写出稳定且有价值的测试。
单元测试入门:代码质量的第一道防线
你改过那种“祖传项目”吗?
- 你只是想把一个变量名改得更清楚。
- 你也只是动了一个 if。
- 结果线上报警:支付回调解析失败。
这时候你会突然理解一句话:
代码不是你写的,是你“改坏”的。
单元测试的价值,不是让你写得更“完美”,而是让你改得更“放心”。它是第一道防线:在 bug 跑到用户那儿之前,先把它绊倒在你的电脑(或者 CI)里。
这篇我不打算讲那种“测试是什么”的教科书定义。我们直接聊更现实的三件事:
- 单元测试到底测什么(以及不测什么)
- 怎么把测试写得稳定、可维护、能挡刀
- 在前端/Node/TypeScript 项目里怎么落地(含可运行示例)
一、单元测试测什么?先把“单元”讲明白
很多人写单元测试写着写着就写成了“点点点 UI 测试”,或者写成了“启动整个数据库的集成测试”。
问题不在于这些测试不能写,而在于:你本来想买一把小刀削苹果,结果你把推土机开进厨房。
1.1 你测的不是函数,是“行为”
单元测试的对象通常是一个小而明确的行为:输入是什么,输出应该是什么,边界情况怎么处理。
比如下面这个小函数:把“金额字符串”解析成分(整数)。
// money.ts
export function parseCents(input: string): number {
const trimmed = input.trim()
if (trimmed === '') throw new Error('EMPTY')
// 允许:"12" "12.3" "12.34"
if (!/^(\d+)(\.\d{1,2})?$/.test(trimmed)) throw new Error('INVALID')
const [yuan, decimal = ''] = trimmed.split('.')
const cents = (decimal + '00').slice(0, 2)
return Number(yuan) * 100 + Number(cents)
}
这就是一个很典型的单元测试对象:
- 不需要网络
- 不需要数据库
- 不需要浏览器
- 行为边界很明确
1.2 单元测试不测什么:不测“别人家的正确性”
如果你的函数调用了第三方 SDK、网络请求、文件系统——你不应该在单元测试里验证它们是否真的工作。
你要验证的是:
- 当依赖返回 A 时,你的逻辑是否处理正确
- 当依赖抛错时,你是否按预期兜底
否则你会得到一种很“热闹”的测试:
- 运行慢
- 偶发失败
- 一失败就没人愿意修
测试一旦变成“警报器经常乱叫”,它在团队里就会被静音。
二、一套可落地的判断:这段代码值不值得写单测?
不是所有代码都值得单测。尤其是那种“写了也挡不住任何 bug”的测试——只会让你未来更痛苦。
小明给你一个非常实用的评估:
2.1 值得写单测的 3 类代码
- 业务规则 / 计算逻辑(最值)
- 折扣、计费、权限、风控规则
- 解析/格式化(日期、金额、协议字段)
- 边界和异常多的逻辑
- 空值、非法输入、超长、精度、四舍五入
- 会被频繁改动的核心模块
- 越是“总在改”的地方,越需要测试当护城河
2.2 不太值得写单测的 3 类代码
- 纯胶水代码(把 A 传给 B)
- UI 细节(像素级样式)
- 第三方库本身(别替库作者写测试)
当然,这不是绝对的。关键是你要问自己一句话:
这条测试失败时,我能立刻定位问题,并且愿意修吗?
如果答案是“不”,那你写的不是测试,是未来的技术债。
三、测试写得像人类:稳定、清晰、可维护
单元测试写得“像人类”,不是说文采,而是说:
- 失败信息一眼能看懂
- 不依赖外部环境
- 不同用例之间互不污染
- 改业务时,测试能陪你一起改,而不是跟你对着干
下面是最常见的三种“测试写烂了”的原因。
3.1 不稳定的根源:时间、随机数、并发
如果你的逻辑依赖当前时间:
export function isExpired(expireAt: number): boolean {
return Date.now() > expireAt
}
这种函数测起来很容易写出“偶尔失败”的测试,因为你测的是时间流逝。
正确做法是把不确定性“抽出去”:
export function isExpired(expireAt: number, now: number): boolean {
return now > expireAt
}
测试就会变得非常稳:
now=1000、expireAt=999→ truenow=1000、expireAt=1000→ false(看你定义)
这招叫:依赖注入(Dependency Injection)。别被名词吓到,它的本质就是“别在函数里偷偷读环境”。
3.2 可维护的秘诀:AAA 结构(Arrange / Act / Assert)
写测试不要把所有逻辑糊成一坨。推荐用 AAA:
- Arrange:准备数据和依赖
- Act:执行被测函数
- Assert:断言结果
用 Vitest 举个例子(Jest 同理):
import { describe, it, expect } from 'vitest'
import { parseCents } from './money'
describe('parseCents', () => {
it('parses integer yuan', () => {
// Arrange
const input = '12'
// Act
const result = parseCents(input)
// Assert
expect(result).toBe(1200)
})
it('parses 1 decimal', () => {
expect(parseCents('12.3')).toBe(1230)
})
it('parses 2 decimals', () => {
expect(parseCents('12.34')).toBe(1234)
})
it('throws on invalid', () => {
expect(() => parseCents('12.345')).toThrowError('INVALID')
expect(() => parseCents('abc')).toThrowError('INVALID')
expect(() => parseCents('')).toThrowError('EMPTY')
})
})
读起来就像一份“规格说明书”。这就是好测试。
3.3 不要迷信覆盖率:覆盖率是温度计,不是药
覆盖率可以告诉你:哪些代码根本没测到。
但覆盖率不能告诉你:
- 你测到的行为有没有价值
- 你的断言是不是“摆设”
- 你是不是只是在跑一遍函数
你见过这种“100% 覆盖率”的测试吗:
it('works', () => {
parseCents('12.34')
expect(true).toBe(true)
})
看起来覆盖了代码,实际上挡不住任何 bug。
更好的目标是:覆盖关键业务路径 + 覆盖边界条件。
四、Mock 到底怎么用?别把自己 Mock 进坑里
Mock 是把“双刃剑”。用好了,让单测更快更稳定;用坏了,你会测到一堆“假的世界”,最后线上还是炸。
4.1 什么时候该 Mock?
当你的代码依赖这些东西时,单元测试应该 Mock:
- 网络请求(fetch/axios)
- 数据库
- 文件系统
- 第三方 SDK(支付、短信、地图)
4.2 Mock 的底线:Mock 边界,不 Mock 细节
假设你有一个函数:拉取用户并做最小处理。
// userService.ts
export type HttpClient = {
get: <T>(url: string) => Promise<T>
}
export async function fetchUserName(client: HttpClient, id: string) {
if (!id) throw new Error('MISSING_ID')
const user = await client.get<{ name: string }>(`/users/${id}`)
return user.name.trim()
}
这段代码的“边界”是 HttpClient。测试时我们只需要 mock client.get 的返回值:
import { describe, it, expect, vi } from 'vitest'
import { fetchUserName } from './userService'
describe('fetchUserName', () => {
it('returns trimmed user name', async () => {
const client = {
get: vi.fn().mockResolvedValue({ name: ' 小明 ' })
}
const name = await fetchUserName(client, '123')
expect(client.get).toHaveBeenCalledWith('/users/123')
expect(name).toBe('小明')
})
it('throws on missing id', async () => {
const client = { get: vi.fn() }
await expect(fetchUserName(client, '')).rejects.toThrowError('MISSING_ID')
})
})
你会发现:
- 测试不需要真的发请求
- 测试仍然验证了关键行为(URL 拼接、trim、异常)
- 失败时定位清晰
这就是“Mock 边界”。
如果你去 Mock trim()、Mock 字符串拼接……你就走火入魔了。
五、把单测真正接进工程:让它在 CI 里变成“门禁”
单元测试只有跑起来才有意义。
你可以把它接进 CI(比如上一篇我们写的 GitHub Actions):
- PR 提交必须通过
pnpm test - 没过就不允许合并
这会带来一个很现实的变化:
- 以前:靠“口头保证”代码没问题
- 现在:靠“可重复的证据”证明代码没问题
5.1 推荐的脚本习惯
在 package.json 里保持清晰的脚本命名:
linttesttest:watchtest:coverage
脚本统一后,CI 和本地就能用同一条命令复现问题。
六、最后总结:单测是“改代码的底气”,不是“写代码的负担”
你可以把单元测试当成一种“长期的保险”。它不保证你永远不出事故,但能把事故从“致命”变成“可控”。
今天的要点,记住这几条就够了:
- 单测测的是行为,不是环境
- 优先覆盖业务规则和边界条件
- 把不确定性(时间、随机数、IO)从函数里抽出去
- Mock 边界,不 Mock 细节
- 让测试在 CI 里当“门禁”,别靠人肉祈祷
小明冷笑话收尾:
没有单元测试的项目,就像没有刹车的自行车:你以为你骑得很快,其实你只是在提前体验摔跤。