让测试跑得快、活得久
测试写出来只是开始。它会和产品代码一起活很多年,期间被无数次运行、阅读、修改。而人们常常忘记:测试也是代码,也会腐烂。一套腐烂的测试 —— 又慢、又难懂、一改就崩、失败了看不出为啥 —— 会从"保障"变成"负担",最后落到没人敢动、干脆全体忽略的境地。到那一步,它比没有测试更糟(你付了成本却拿不到收益)。这最后一课讲可维护性:怎么让测试读起来清楚、改起来轻松、跑起来够快,能陪你的代码活很久。
测试的读者,是未来那个 debug 的人
写产品代码时,你的读者是"未来的维护者"。写测试时,读者更具体:是那个盯着 CI 一片红、想搞清楚哪儿坏了的人。所以测试的第一美德不是"聪明",是清楚。这条原则,推出了下面几乎所有的做法。
DAMP,而不是 DRY
产品代码奉行 DRY(Don't Repeat Yourself,别重复)。但测试代码,有一条几乎相反的准则:DAMP(Descriptive And Meaningful Phrases,描述性、有意义)。
产品代码里,你会把重复逻辑抽成函数。但在测试里,过度抽象是灾难:当你把一堆 setup 藏进层层嵌套的辅助方法、共享的基类、神秘的 fixture 里,读一个测试就得跳七八个文件才知道它在测什么。测试失败时,你根本看不出发生了什么。
DAMP 说:宁可让每个测试有点重复,也要让它自己把话说完。一个测试应该"就地"把它的前提、动作、期望摆清楚,读者不用翻别处就能懂。适度的重复,是可读性的合理代价。
当然,这不是说测试里所有重复都好。有一种重复该消除 —— 那些又长又没信息量的对象构造。解法不是藏进辅助方法,而是测试数据构造器。
测试数据构造器:让 Arrange 一眼看懂
看一个典型的痛点:测试需要一个 User,但 User 有十个字段,而这个测试只关心其中一个(age)。天真的写法把无关字段全列出来,淹没了重点:
// ✗ 噪音淹没重点:这个测试其实只关心 age
val user = User(1, "Ann", "ann@x.com", "13800000000",
Address("...", "..."), Role.NORMAL, true, 0, now(), now())
// ✓ 构造器 + 默认值:只写出你关心的那个字段
val user = aUser().withAge(17).build() // 其余字段都是合理默认值
测试数据构造器(builder / object mother)给每个字段合理默认值,让你只需要写出"和这个测试相关"的那一两个字段。aUser().withAge(17) —— 一眼就知道"这个测试和 17 岁有关"。这既消除了噪音,又没把逻辑藏到远处(它就在你眼前,只是省略了无关的默认值)。这是测试里"消除重复"的正确方式:消除无意义的噪音,而非藏起有意义的意图。
一个失败,一眼定位
可维护性的另一半,是失败时它有多好懂。这里把前面几章的相关点收拢成一组规矩:
- 好名字(第 2 章):失败列表里,一个"给定-当-则"的名字直接告诉你哪个行为坏了。
- 一个失败理由(第 2 章):一个测试只测一件事,红了就知道是哪件,不用挨个注释去试(断言轮盘)。
- 会说话的断言(第 2 章):
assertEquals/assertThat而非assertTrue,失败信息直接给出期望和实际。 - 失败信息带上下文:必要时用
.describedAs("八折后价格")/clue("..."),让红出来的那行自解释。
这些加起来的效果是:一个测试红了,你看一眼失败列表和信息,就知道发生了什么、去哪儿找 —— 不用打开测试代码、不用加日志、不用重跑。这个"一眼定位"的体验,是可维护测试和折磨人测试的分水岭。
别怕删测试
人们对删测试有种莫名的敬畏,好像测试越多越好。不对。
有些测试该删:重复的(五个测试测同一件事)、过时的(测的功能已经没了)、重言式的(第 18 章,永远绿的假测试)、过度精确的(第 18 章,一改无关代码就碎)。留着它们,只是徒增维护成本和噪音,还拖慢套件。
判断标准回到第 1 章:一个测试的价值,等于它"本可能失败并因此告诉你有 bug"的程度。一个永远不会有效失败的测试,删掉它,套件反而更健康、更快、更可信。
速度:必要的慢,和你自找的慢
第 4 章讲过速度是测试的生死线。到了维护阶段,要区分两种慢:
| 慢的来源 | 性质 | 怎么办 |
|---|---|---|
| 层级本身(e2e 要启动全栈) | 必要的慢 | 接受,但控制数量(金字塔,第 4 章) |
Thread.sleep / 真延迟 | 自找的慢 | 删掉,改等条件(第 12、17 章) |
| 每个测试都重建整个环境 | 自找的慢 | 共享只读 fixture,只重建可变部分 |
| 碰了真 IO(磁盘/网络/真库) | 自找的慢 | 用内存版 / fake(第 6、10 章) |
| 串行跑一大堆独立测试 | 自找的慢 | 分片并行(第 22 章) |
大部分"测试套太慢",不是必要的慢,是一堆自找的慢累加起来的。逐个消除它们,通常能把一套 10 分钟的测试压到 1 分钟以内 —— 而快,直接决定了这套测试会不会被频繁跑、会不会活下去。
AI 与测试维护
好消息:AI 相当擅长测试的机械性维护 —— 提取一个测试数据构造器、把 assertTrue 批量换成会说话的断言、给测试补上描述性的名字、找出重复的测试。这些"改形不改意"的活,交给它很合适,你只要跑一遍确认行为没变(第 3 章)。
但两件事仍是你的判断:删哪个测试(哪个是重言式、哪个过时了 —— 这需要理解意图),和它抽象得对不对(AI 有时会 DRY 过头,把 setup 藏进层层辅助方法,违反 DAMP)。审查 AI 的测试重构,盯一点:改完之后,一个测试是不是还能"就地读懂"?如果它把你送去翻三个文件才明白测什么,那它优化错了方向。
构造器怎么写:默认值 + 链式覆盖
测试数据构造器的实现,在 Kotlin 里很轻。用带默认值的工厂函数,或一个小 builder:
// 最简版:一个带默认值的工厂函数就够了
fun aUser(
id: Int = 1,
name: String = "测试用户",
email: String = "test@x.com",
age: Int = 30,
role: Role = Role.NORMAL,
) = User(id, name, email, age, role /* ... 其余字段都有合理默认 */)
// 用的时候只写你关心的:
val teen = aUser(age = 17) // 这个测试关心"未成年"
val admin = aUser(role = Role.ADMIN) // 这个测试关心"管理员"
val ann = aUser(name = "Ann", email = "ann@x.com")
aUser(age = 17) 一眼就说清「这个测试和未成年有关」,其余二十个无关字段安静地待在默认值里,不占用读者的注意力。这比「把整个 User 构造藏进一个 setupUser() 辅助方法」好得多 —— 后者把信息藏走了(DAMP 的反面),前者只是把噪音藏走、意图留在眼前。
自定义断言:让失败信息说业务语言
可读性还能再进一步。当你反复断言同一组条件,可以封一个领域断言,让测试和失败信息都说人话:
// 封装一个业务语义的断言
fun assertIsPaidOrder(order: Order) {
assertThat(order.status).isEqualTo(OrderStatus.PAID)
assertThat(order.paidAt).isNotNull()
}
// 测试里读起来像需求:
@Test fun `支付成功后订单已支付`() {
val order = service.pay(anOrder())
assertIsPaidOrder(order) // 一行,语义清晰,失败信息也精确
}
注意这和「把 setup 藏进辅助方法」不同 —— 这里抽走的是断言(一组反复出现的、有明确业务含义的检查),而且它让失败信息更清楚,不是更模糊。抽象得对不对,判据始终是:它让测试更好读、失败更好懂了,还是更难追了?
命名约定:让失败列表成为一份文档
第 2 章说过好名字的重要性,这里给一套可以团队统一的约定。挑一种坚持用:
| 风格 | 例子 |
|---|---|
| given-when-then | `给定过期券_当结算_则抛异常`() |
| 方法_场景_期望 | `checkout_空购物车_返回0`() |
| should 句式 | `should 打八折 when 满100`() |
关键不是选哪种,是全队一致、且名字里含足够信息。当 CI 挂了 40 个测试,一列清晰的名字能让你不打开任何代码,就大致知道「是优惠模块的边界处理集体出问题了」。名字排在一起,本身就是一份活的行为规格 —— 这是命名之外的额外红利。
1. 找你项目里最难读的一个测试。它是不是把 setup 藏得太深(违反 DAMP)?试着把关键信息拉回测试本体。
2. 给一个字段很多的领域对象写一个测试数据构造器,然后看用它写的测试是不是清爽多了。
3. 大胆删一个测试:找一个重复的、或永远绿的重言式测试,删掉它。套件没变弱,反而更诚实了。
最后一章了。我们回到起点 —— 回到第 1 章那段 AI 写的、看起来完美的代码。把全书压成一张你能在写代码时当场用出来的判断表:什么该测、什么不该测、测到哪一层。以及一个诚实的收尾。