一个测试的解剖:Arrange-Act-Assert 与一句断言
你可能觉得测试是个很有技术含量的东西 —— 各种框架、注解、mock、fixture。但把它拆到最里面,一个测试简单得几乎令人失望:准备好东西、跑一下、看结果对不对。三段,就这些。真正需要动脑子的,是最后那半句 assert:它是你把「对」这个模糊的词,翻译成机器能判定的、一个具体表达式的地方。断言写好了,失败时它会直接把答案递到你手上;写砸了,它只会告诉你「不对」,然后让你自己去查半小时。
三段:Arrange-Act-Assert
几乎所有单元测试,不管什么语言什么框架,都长成同一个形状。这个形状有个名字,叫 AAA:
@Test
fun `满 100 打八折`() {
// Arrange —— 准备:把被测对象和输入摆好
val cart = Cart()
cart.add(Item("书", price = 100))
// Act —— 执行:调那一个你要测的行为,拿到结果
val total = cart.checkout(coupon = "SAVE20")
// Assert —— 断言:结果必须等于你期望的那个
assertEquals(80, total)
}
三段各司其职,而且顺序不能乱:
- Arrange(准备):构造被测对象、准备输入、设置好依赖(必要时用测试替身,第 10 章)。这一段是「把世界摆成一个已知的样子」。
- Act(执行):调用你要测的那一个行为。注意是一个 —— 一个测试里如果 Act 了三次,通常说明你在一个测试里测三件事(下面会说为什么这不好)。
- Assert(断言):拿实际结果,和你的期望比。不相等,测试就 FAIL。
Arrange 可以很短(有时一行都不用),Act 可以只是一次函数调用,但没有 Assert 的测试根本不是测试 —— 它只是「把代码跑了一遍没崩」。跑一遍没崩,连「它做了对的事」都没验证(第 15 章会看到,这正是很多假测试的真面目)。
Web 那边一模一样,只是换了身衣服。Vitest / Jest 里,AAA 常写成 given / when / then 的注释,或者干脆靠空行分段:
test('满 100 打八折', () => {
const cart = new Cart() // arrange
cart.add({ name: '书', price: 100 })
const total = cart.checkout('SAVE20') // act
expect(total).toBe(80) // assert
})
断言:你对「对」的定义
现在聚焦最后那一句。它看起来只是一次相等比较,但它承担的东西,是整个测试里最重的:
「这个函数应该是对的」是一句人话,机器听不懂。assertEquals(80, total) 是它的机器翻译:把一个含糊的期望,钉成一个具体的、能被判真判假的表达式。
所以写断言,本质上是在做一件很难的事 —— 说清楚你到底想要什么。这也是为什么第 1 章说,你写测试(把「对」的定义钉死),AI 写实现:定义「对」是意图,是你的责任;满足这个定义是劳动,可以外包。
而断言这半句里,还藏着一个常被忽略、却直接影响你效率的选择:你用哪种写法。同样是判断相等,不同写法在失败的那一刻,给你的信息量天差地别。下面这个 Demo,拿一个真有 bug 的 applyDiscount 让它失败,看三种断言各自吐出什么。
拖动滑块,让实际值和期望值分开(比如 price=200)。你会看到:
assertTrue(result == expected)—— 失败信息是 expected: true but was: false。它把相等比较的结果压成了一个布尔,于是「谁是谁」的信息全丢了。你只知道「不相等」,得回去加日志重跑,才知道到底差多少。assertEquals(expected, result)—— 失败信息是expected: <160> but was: <180>。期望和实际都在,一眼看到差在哪。90% 的场景这就够了。assertThat(result).isEqualTo(expected)(Truth / AssertJ / Kotlin 的 kotest)—— 链式断言,失败信息最丰富,还能挂上下文:.withMessage("八折后价格")。
每当你想写 assertTrue(a == b)、assertTrue(list.size == 3)、assertTrue(result.contains(x)) —— 停一下。这些几乎总有一个「会说话」的版本:assertEquals、assertThat(list).hasSize(3)、assertThat(result).contains(x)。
差别只在失败那一刻显现:assertTrue 说「false」,而会说话的版本直接告诉你「size 是 5 不是 3」。把 debug 需要的信息,提前写进断言里。
把默认滑块停在 price=100 试试 —— 你会发现它居然通过了。因为这个 bug(把「减 20%」写成「减 20 元」)在 100 这个点上,八折(80)刚好等于减 20(80)。一个恰好蒙对的输入,让 bug 溜了过去。这正是下一幕「挑输入」要防的事 —— 断言写得再好,喂错了输入也白搭。
一个测试,只问一个问题
回到 AAA 里那个「Act 只做一次」的规矩。它背后是一条更根本的原则:
不是说一个测试里只能有一句 assert(有时一个逻辑概念需要好几句来描述,比如「返回的用户,名字对、年龄对、id 对」)。而是说:这些断言应该都在验证同一件事。如果它们在验证三件不相关的事,那就是三个测试挤在了一起。
为什么这很重要?因为断言是「短路」的 —— 第一句失败,后面就不跑了。看这个反例:
// 坏味道:一个测试,四件事,红了不知道是哪件
@Test
fun `测试用户功能`() {
assertEquals("Ann", user.name) // ← 如果这句就挂了
assertEquals(30, user.age) // 下面三句根本没跑
assertTrue(user.isActive) // 你以为只有 1 个 bug
assertEquals(2, user.orders.size) // 其实可能有 4 个
}
这叫断言轮盘(assertion roulette)(第 18 章的常客):红了,你不知道是四件事里的哪一件,还得挨个注释掉去试。拆成四个命名清晰的测试,红哪个一目了然,而且它们各自独立,一个 bug 不会挡住另一个的暴露。
命名:失败时,你只看得到名字
测试的名字不是给你自己看的,是给三个月后那个盯着 CI 一片红的人看的 —— 那人可能就是你。他第一眼看到的,不是测试代码,是失败列表里的一行名字。所以名字必须自己把话说完:
// ✗ 什么也没说
@Test fun test1() { ... }
@Test fun testCheckout() { ... }
// ✓ 一句话说清「什么条件下,应该发生什么」
@Test fun `空购物车结算返回 0`() { ... }
@Test fun `满 100 用 SAVE20 券打八折`() { ... }
@Test fun `优惠券过期时抛 CouponExpired`() { ... }
Kotlin 允许用反引号写中文/带空格的函数名,这在测试里是特权,别浪费。一个好名字通常包含三要素:前提(given)、动作(when)、期望(then)。「满 100 用 SAVE20 券 打八折」—— 前提、动作、期望齐了。IDE 的测试报告里,这些名字排成一列,本身就是一份可读的规格文档。
AI 与断言:最不能外包的半句
第 1 章说「你写测试,AI 写实现」。但如果你让 AI 连测试也一起写,要格外盯住的,就是断言这半句。因为 AI 写测试时,有两个根深蒂固的坏习惯(第 19 章会完整拆解):
一是写「哑」断言。它偏爱 assertNotNull(result)、assertTrue(result.isNotEmpty()) 这种几乎不可能失败的断言 —— 看起来测了,其实只验证了「没崩、非空」,完全没碰「值对不对」。
二是断言「当前行为」而非「正确行为」。如果实现有 bug,AI 常常照着 bug 的输出来写期望值:实现算出 NaN,它就写 assertTrue(result.isNaN()),把 bug 固化成了「规格」。测试于是永远绿 —— 因为它测的是「代码有没有保持它的错」。
破解办法很朴素:断言里的那个期望值,必须由你独立算出来,而不是从代码的输出里抄。「满 100 打八折是 80」这个 80,是你按业务规则算的,不是运行一遍代码得到的。这条界线看着小,却是「测试」和「把 bug 拓印下来」之间的全部区别。
1. 把上面 Demo 的滑块拖到 200、150、100,记下三种断言各自的失败信息。体会 assertTrue 丢掉了多少东西。
2. 翻一个你写过的测试,数它有几个「失败的理由」。如果超过一个不相关的,试着拆开,给每个起一个「前提-动作-期望」的名字。
3. 找一处 assertTrue(a == b),换成会说话的断言。下次它失败时,你会谢自己。
三段结构、一句断言、一个好名字 —— 一个测试的全部构造就这些。下一章,我们把这三段编排成一种节奏:先写会失败的测试(红)、再让它通过(绿)、然后放心重构。这就是 TDD,而在 AI 时代,它有了一个新的、更实在的理由。