反模式图鉴:十种「测了个寂寞」
前面几章拆的是单个陷阱(覆盖率、变异、flaky)。这一章把测试世界里最常见的坏味道汇成一份图鉴 —— 十种「看起来在测试、其实没测到点子上」的写法。它们有的抓不到 bug,有的一重构就全红,有的慢得没人愿意跑。共同点是:它们消耗了写测试的成本,却拿不到测试该有的收益。把这份图鉴装进脑子,你在 code review 时(尤其是审 AI 写的测试时)就能一眼认出它们,并知道该怎么改。
一张速查表
先给全景,再逐个细说重点的几个:
| 反模式 | 症状 | 怎么改 |
|---|---|---|
| ① 测实现而非行为 | 不改行为、只重写实现,测试就红 | 断言结果/状态,别断言调用序列(第 11 章) |
| ② 断言轮盘 | 一个测试十个断言,红了不知哪句 | 拆成多个、每个一个「失败理由」(第 2 章) |
| ③ happy-path 独苗 | 只测正常输入,没有边界/失败 | 补边界值、空、null、异常路径(第 9 章) |
| ④ 没有断言 | 调一遍就完,只为刷覆盖率 | 加真正验证结果的断言(第 15 章) |
| ⑤ 重言式 | 测试用和实现一样的逻辑算期望值 | 期望值独立手算或用对照预言 |
| ⑥ 神秘嘉宾 | 依赖外部文件/网络/共享库 | 测试自带数据,保持密封(第 6 章) |
| ⑦ 过度精确 | 断言了一堆你根本不在乎的细节 | 只断言这个测试关心的那件事 |
| ⑧ 雪人测试 | 靠 sleep 硬等,又慢又 flaky | 等条件不等时间(第 12、17 章) |
| ⑨ 相互依赖 | 测试必须按某个顺序跑 | 每个测试独立自足(第 5 章 FIRST-I) |
| ⑩ 冰淇淋蛋筒 | 端到端堆成主力,又慢又脆 | 把验证下沉到单元/集成(第 4 章) |
①②③④⑧⑨⑩ 前面都展开过了,这里补讲三个没细说、又特别隐蔽的:重言式、神秘嘉宾、过度精确。
⑤ 重言式:测试和实现,犯同一个错
这是最阴险的一种,因为它永远绿,而且看起来很认真。问题出在期望值是怎么来的:
// 被测实现(有 bug:漏了闰年)
fun daysInFebruary(year: Int): Int = if (year % 4 == 0) 29 else 28
// 重言式测试:用一模一样的逻辑算期望值
@Test fun `二月天数`() {
val expected = if (2100 % 4 == 0) 29 else 28 // ← 抄了实现的逻辑!
assertEquals(expected, daysInFebruary(2100)) // 永远绿,哪怕都错了
}
2100 年不是闰年(百年不闰,四百年再闰)—— 实现错了,但测试用了同样错误的逻辑算期望值,于是两个错误互相抵消,测试骗你说"对"。这叫重言式(tautology):测试没有独立于实现,它只是把实现复述了一遍。
解药:期望值必须来自独立的真相源 —— 你查日历知道 2100 年二月是 28 天,直接 assertEquals(28, daysInFebruary(2100))。这正是第 2 章那句"期望值要独立算,不能从代码输出里抄"的深层原因。
⑥ 神秘嘉宾:测试依赖看不见的外部东西
一个测试读起来完整,但它其实偷偷依赖一个不在测试里、你也看不见的东西 —— 一个外部文件、一条数据库记录、一个网络服务、一个环境变量。
@Test fun `解析用户`() {
val json = File("/data/fixtures/user.json").readText() // ← 神秘嘉宾
val user = parse(json)
assertEquals("Ann", user.name) // 为什么是 Ann?看这个测试根本不知道
}
神秘嘉宾的坏处:① 别人看不懂(Ann 从哪来的?);② 那个文件被谁改了、丢了,测试就莫名其妙红(flaky 的一种);③ 换台机器/换个环境就跑不了。把数据内联进测试(直接在测试里写那段 JSON),测试才自足、可读、密封。
⑦ 过度精确:断言了太多你不在乎的东西
和"没有断言"相反的另一个极端:一个测试断言了一大堆和它的主旨无关的细节,于是任何无关的改动都让它红。
// 这个测试想验证"注册成功",却顺手断言了一堆无关细节
@Test fun `注册成功`() {
val user = service.register("ann@x.com")
assertEquals("ann@x.com", user.email)
assertEquals(User.Role.NORMAL, user.role)
assertEquals("2026-07-12T10:30:00.123Z", user.createdAt) // ← 精确到毫秒?!
assertEquals(0, user.loginCount)
assertEquals("#4A90D2", user.avatarColor) // ← 关注册什么事
}
那个精确到毫秒的 createdAt 让这个测试天生 flaky(时间每次都不同)。那个 avatarColor 让"改个默认头像色"这种无关改动也能弄红它。过度精确的测试又脆又吵 —— 它把大量"我其实不在乎"的东西也钉死了。
原则:一个测试只断言它主旨相关的那几件事。"注册成功"就断言 email 对、状态对;createdAt 用"非空/大致范围"而不是精确值;头像色根本不该在这个测试里出现。这和过度 mock(第 11 章)是同一种病的两个器官:把不该管的细节也管了。
共同的病根
把这十种排一排,你会发现它们其实归为两大类病根:
太紧(和实现绑太死):① 测实现、⑤ 重言式、⑦ 过度精确、⑩ 冰淇淋蛋筒的脆性。它们把测试焊在了实现细节上,导致重构即全红,但真 bug 未必抓到。
太松(验证不到位):③ happy-path 独苗、④ 没有断言。它们看起来在测,实则该问的没问、该断言的没断言。
再加上一类不确定性(⑥ 神秘嘉宾、⑧ 雪人、⑨ 相互依赖)带来的 flaky。好测试的手艺,基本就是在"太紧"和"太松"之间找那个刚刚好、并且确定的点。
AI 时代:它是这份图鉴的高产作者
回看这十条,AI 写测试时几乎每条都可能命中:它爱 ① 测实现(过度 mock + verify)、④ 写没断言的凑数测试、③ 只测 happy path、⑤ 照着实现算期望值(尤其当它同时写实现和测试时)、⑦ 把它"看到"的所有字段都断言一遍。
为什么?因为这些反模式在训练语料里大量存在,而 AI 缺的恰恰是分辨"这样测好不好"的判断力 —— 它能写出形似专业测试的代码,却不理解什么让测试真正有用。这份图鉴,就是你审查 AI 测试的检查清单。下一章,我们正面把"AI 写测试"这件事彻底拆开。
三个「测试代码里的坏味道」
前面几条讲的是「测得不对」。还有一类是「测试代码本身写得像坨浆糊」—— 它们在 code review 里最该被拦下。
测试里有逻辑
测试代码里出现了 if、for、when,是个强烈的警报:
// ✗ 测试里有分支:哪条路径跑了?不确定。逻辑对不对?没人测这个测试
@Test fun `折扣`() {
for (total in listOf(50, 100, 150)) {
val expected = if (total >= 100) total * 8 / 10 else total // ← 重言式 + 逻辑
assertEquals(expected, discount(total))
}
}
这段同时犯了两条:循环 + 分支让你不知道哪个 case 真的跑了、红了是哪个;而那个 if 算期望值,又是重言式(抄了实现逻辑)。改法:拆成几个具体的、期望值写死的测试,或用参数化测试(@ParameterizedTest + 明确的「输入→期望」对照表)。测试代码应该是「一条直线」:摆输入、调、断言,没有分叉。
条件断言:悄悄什么都没测
// ✗ 如果 result 是 null,整个断言被跳过 —— 测试"通过"了,却什么也没验
@Test fun `查询`() {
val user = repo.find(1)
if (user != null) {
assertEquals("Ann", user.name) // user 为 null 时,这里根本不执行
}
}
这个测试在 find 返回 null(一个 bug)时,会安静地绿 —— 因为 if 把断言跳过了。把条件去掉,让 null 直接炸:assertEquals("Ann", user!!.name) 或先 assertNotNull(user)。断言不该藏在 if 后面,否则那个「测了个寂寞」你根本发现不了。
DRY 过头:setup 藏进层层继承
和第 23 章呼应的反面:为了「不重复」,把 setup 抽进基类、抽进层层辅助方法,结果读一个测试得跳七八个文件。测试代码奉行 DAMP(描述性)而非 DRY —— 适度的重复,好过把意图藏到远处。一个测试红了,你应该能就地读懂它测什么,而不是去考古它的继承链。
1. 拿这张速查表当 checklist,过一遍你项目里最老的一个测试文件。中了几条?
2. 专门找一个"永远绿"的测试,查它是不是重言式(期望值抄了实现)或没有真断言。永远绿的测试最可疑。
3. 让 AI 写一个测试,拿速查表逐条对照。数它命中了几种反模式 —— 通常不止一种。