单元测试入门:代码质量的第一道防线

单元测试不是为了“追求覆盖率”,而是为了让改代码不再心虚。本文用可落地的例子讲清楚:测什么、不测什么、怎么写出稳定且有价值的测试。

6 分钟阅读
小明

单元测试入门:代码质量的第一道防线

你改过那种“祖传项目”吗?

  • 你只是想把一个变量名改得更清楚。
  • 你也只是动了一个 if。
  • 结果线上报警:支付回调解析失败。

这时候你会突然理解一句话:

代码不是你写的,是你“改坏”的。

单元测试的价值,不是让你写得更“完美”,而是让你改得更“放心”。它是第一道防线:在 bug 跑到用户那儿之前,先把它绊倒在你的电脑(或者 CI)里。

这篇我不打算讲那种“测试是什么”的教科书定义。我们直接聊更现实的三件事:

  1. 单元测试到底测什么(以及不测什么)
  2. 怎么把测试写得稳定、可维护、能挡刀
  3. 在前端/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 类代码

  1. 业务规则 / 计算逻辑(最值)
    • 折扣、计费、权限、风控规则
    • 解析/格式化(日期、金额、协议字段)
  2. 边界和异常多的逻辑
    • 空值、非法输入、超长、精度、四舍五入
  3. 会被频繁改动的核心模块
    • 越是“总在改”的地方,越需要测试当护城河

2.2 不太值得写单测的 3 类代码

  1. 纯胶水代码(把 A 传给 B)
  2. UI 细节(像素级样式)
  3. 第三方库本身(别替库作者写测试)

当然,这不是绝对的。关键是你要问自己一句话:

这条测试失败时,我能立刻定位问题,并且愿意修吗?

如果答案是“不”,那你写的不是测试,是未来的技术债。


三、测试写得像人类:稳定、清晰、可维护

单元测试写得“像人类”,不是说文采,而是说:

  • 失败信息一眼能看懂
  • 不依赖外部环境
  • 不同用例之间互不污染
  • 改业务时,测试能陪你一起改,而不是跟你对着干

下面是最常见的三种“测试写烂了”的原因。

3.1 不稳定的根源:时间、随机数、并发

如果你的逻辑依赖当前时间:

export function isExpired(expireAt: number): boolean {
  return Date.now() > expireAt
}

这种函数测起来很容易写出“偶尔失败”的测试,因为你测的是时间流逝。

正确做法是把不确定性“抽出去”:

export function isExpired(expireAt: number, now: number): boolean {
  return now > expireAt
}

测试就会变得非常稳:

  • now=1000expireAt=999 → true
  • now=1000expireAt=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 里保持清晰的脚本命名:

  • lint
  • test
  • test:watch
  • test:coverage

脚本统一后,CI 和本地就能用同一条命令复现问题。


六、最后总结:单测是“改代码的底气”,不是“写代码的负担”

你可以把单元测试当成一种“长期的保险”。它不保证你永远不出事故,但能把事故从“致命”变成“可控”。

今天的要点,记住这几条就够了:

  • 单测测的是行为,不是环境
  • 优先覆盖业务规则边界条件
  • 把不确定性(时间、随机数、IO)从函数里抽出去
  • Mock 边界,不 Mock 细节
  • 让测试在 CI 里当“门禁”,别靠人肉祈祷

小明冷笑话收尾:

没有单元测试的项目,就像没有刹车的自行车:你以为你骑得很快,其实你只是在提前体验摔跤。