阅读提要

覆盖率 90% 但线上照样出问题,说明测的不是该测的地方。单元测试应该覆盖「你的判断」,不是覆盖「库的行为」。

开始阅读

字号

一个常见的误区

为了让覆盖率好看,很多人会给每个函数都写测试,包括「调用了一次 ORM,返回了结果」这种。

这类测试的典型特征:mock 了一大堆依赖,断言的是「mock 被调用了几次」。它测的是「代码写了什么」,不是「代码做对了什么」。重构一次,测试全红,但功能没变。

真正该被单元测试覆盖的,是含有判断的地方。

测试分层

什么值得单测

纯函数。输入确定、输出确定、没有副作用。这类测试写得快、跑得快、几乎不会误报。

test('分页参数越界时收敛到合法范围', () => {
  expect(normalizePage(0, 10)).toBe(1)
  expect(normalizePage(-3, 10)).toBe(1)
  expect(normalizePage(1e9, 10)).toBe(1e9)
})

边界条件。空数组、null、超长字符串、时区、闰年、并发上限。这些是 bug 的高发区,也是最容易被手工测试漏掉的地方。

业务规则。折扣怎么算、权限怎么判、状态机怎么流转。这些规则一旦写错,影响的是钱和权限,值得用测试钉死。

解析与序列化。Markdown 转 HTML、JSON 校验、URL 解析。输入空间大,人肉测不完。

什么不值得单测

框架行为。测「Vue 的 v-if 能否隐藏元素」没有意义,框架自己测过了。

纯转发。function getUser(id) { return db.getUser(id) },测它只能 mock 数据库然后断言调用,等于把实现抄一遍。

UI 布局。像素级位置交给视觉回归或端到端,单测断言 className 只会随样式调整反复失效。

第三方库的封装。除非你的封装里加了逻辑(重试、降级、格式转换),否则不用测。

集成点用契约测试

「服务 A 调服务 B」这种边界,单测很难覆盖,mock 又容易和真实行为脱节。

更好的做法是契约测试:用一份共享的 schema(OpenAPI、JSON Schema)验证两端。

test('响应符合约定 schema', async () => {
  const res = await fetch(`${base}/api/posts?pageSize=1`)
  const data = await res.json()
  expect(() => schema.parse(data)).not.toThrow()
})

它比 mock 可靠,因为验证的是真实响应;比端到端便宜,因为不需要跑整个链路。

覆盖率该怎么看

覆盖率是发现盲区的工具,不是目标。

  • 行覆盖 60%:有一大块代码从没被执行过,值得看;
  • 行覆盖 85%:比较健康;
  • 行覆盖 95% 以上:通常意味着写了一堆「为了覆盖率」的断言,价值不高。

更有用的指标是分支覆盖:if/else 两边都走过吗?错误分支测过吗?很多 bug 藏在「从来没进过的 else 里」。

测试要能失败

写完一条测试,先故意把代码改坏,确认测试变红。

不这么做,很容易写出「永远通过」的测试——比如断言写成了 expect(result).toBeDefined(),而它永远 defined。

一个实用的习惯:先写测试,看它失败,再写实现。 这就是 TDD 的核心价值,不是「测试先行」这个形式,是「确认测试真的在测东西」。

测试的可维护性

三个原则:

测试名要描述行为,不是描述实现。 returns 404 when post is draft 比 calls findById and throws 好,因为前者在重构后仍然成立。

一个测试只断言一件事。 三个断言混在一起,失败时你不知道是哪一步坏了。

测试数据要最小化。 造一个只有必要字段的对象,而不是复制一份完整的生产数据。字段越多,改 schema 时越痛苦。

一套能落地的分层

  • 单元测试:纯函数、业务规则、解析逻辑。快,多写。
  • 集成测试:API + 数据库,用临时库。中等速度,覆盖关键链路。
  • 端到端:少量关键路径(登录、下单、发布文章)。慢,只写最核心的。

比例大致是 70/25/5。反过来(大量 E2E、少量单测)的团队,通常测试跑一次要半小时,然后大家开始跳过测试。

收尾

判断一条测试值不值得写,问一句:如果这段代码写错了,这条测试能发现吗?

能,就写;不能,它只是在增加维护成本。

测试跑得慢会毁掉一切

一条测试 200ms,五百条就是 100 秒。开发者开始本地不跑测试,只等 CI;CI 排队十分钟,于是提交变成「攒一批再推」。

这个链条一旦形成,测试就失去了「快速反馈」的核心价值。

所以单测必须快:不碰网络、不碰真实文件系统、不启动浏览器。用内存数据库或临时目录,把 I/O 降到最低。超过 50ms 的单测,通常说明它其实是集成测试,应该被分到另一组。

别为了覆盖率写测试

覆盖率是结果,不是目标。盯着数字补测试,最后补出来的都是「调用一次、断言不抛错」这种没有信息量的用例。

真正有价值的测试,往往只有几十条,但每一条都对应一个曾经出过错的地方。

你认为这篇文章怎么样?

还没有表情反馈

分享文章