从行为到场景到断言:写一个失败时能说清问题的测试
构建红了,失败的测试叫
testCalculate3,信息是expected: <true> but was: <false>。为了弄清楚是什么错了,得打开测试代码,读懂准备数据的二十行,再在调试器里跑一遍。另一个测试在一次纯粹的重构之后失败了,业务行为一点没变。
一个好的测试在两个时刻有用:失败的时候,它应该直接说出是哪条规则、在什么情况下、错成了什么样;代码重构的时候,只要行为没变,它就不应该失败。这两件事都取决于测试是从什么出发写的:从行为出发,经过具体的场景,落到能说清差异的断言;而不是从实现出发,检查代码内部做了哪些调用。
本文用 JUnit 6.1.3、AssertJ 3.27.7、Mockito 5.24.0,在一段带有几个错误的代码上比较不同的写法给出的失败信息,以及一次重构对两类测试的影响。实验见文末。
一、从行为出发:先写下规则和例子
测试的起点是一条用业务语言描述的行为:「不满 10 公斤运费 10 元,10 公斤及以上运费 20 元」。它不是「shippingFee 方法返回正确的值」,后者没有说出什么是正确的。
从行为到场景,是给规则找具体的例子,尤其是边界上的例子:9 公斤、10 公斤、11 公斤。每个例子有输入、期望和一句理由。这张例子表本身就是可以和业务方确认的东西,规则怎样用例子谈清楚,见用例子把规则谈清楚。
写成参数化测试时,把理由放进显示名:
@ParameterizedTest(name = "{0} 公斤 → 运费 {1}({2})")
@CsvSource({ "9, 10, 不满 10 公斤", "10, 20, 恰好 10 公斤", "11, 20, 超过 10 公斤" })
void fee(int kg, int expected, String why) { assertThat(Shop.shippingFee(kg)).isEqualTo(expected); }fee(int, int, String) "10" 公斤 → 运费 "20"("恰好 10 公斤") 失败:expected: 20 but was: 10
feeWithoutNames(int, int)[2] "10", "20" 失败:expected: 20 but was: 10被测代码把 >= 写成了 >。两种写法都发现了错误,差别在报告里:带显示名的一眼就能看出是「恰好 10 公斤」这条边界错了;不带的只知道第 2 组参数。构建日志、CI 报告里只有这一行时,前者省掉了一次打开代码的过程。
二、断言:让失败信息说出差异
同一个错误(购物车少了一件商品),四种断言写法:
assertTrue(items.contains("banana")) expected: <true> but was: <false>
assertEquals(List.of("apple","banana","pear"), items) expected: <[apple, banana, pear]> but was: <[apple, pear]>
assertThat(items).contains("banana") Expecting List12: ["apple", "pear"] to contain: ["banana"]
assertThat(items).as("alice 的购物车").containsExactly(…) [alice 的购物车] Expecting actual: ["apple", "pear"] to contain exactly (and in same order): …assertTrue 把一个关于集合的判断压缩成了一个布尔值,失败时只剩下「真假不符」,实际的内容丢了。其余三种都保留了实际值,AssertJ 还说明了缺的是哪个元素;as() 给失败信息加上业务描述,在一个测试里有多个断言时尤其有用。
经验规则:断言的形式要和被检查的东西的形状一致。检查集合用集合断言,检查对象的几个字段用字段断言,检查异常用异常断言;避免先算出一个布尔值再 assertTrue。
三、一次报出所有差异
订单摘要有两个字段算错了:
逐条 assertEquals expected: <100.00> but was: <90.00>
assertAll("订单摘要", …) 订单摘要 (2 failures) total ==> expected: <100.00> but was: <90.00>; status ==> expected: <PAID> but was: <NEW>
SoftAssertions Multiple Failures (2 failures) -- failure 1 -- [total] expected: 100.00 …逐条断言在第一个失败处就停了,修好金额再跑一次,才发现状态也错了。分组断言(JUnit 的 assertAll、AssertJ 的 SoftAssertions)会执行所有断言,再一起报告。检查同一个结果的多个方面时用分组断言;后一个断言依赖前一个成立(先断言不为空,再断言字段)时,仍然要逐条写。
四、测行为,不测内部调用
计价从第一版重构到第二版:第一版每一行查一次税率,第二版只查一次,结果完全相同。
behavior_v1 通过 assertThat(totalV1(lines, tax)).isEqualByComparingTo("30.80")
behavior_v2 通过 assertThat(totalV2(lines, tax)).isEqualByComparingTo("30.80")
interaction_v1 通过 verify(tax, times(3)).rate()
interaction_v2 失败 Wanted 3 times … But was 1 time校验调用次数的测试把「每一行查一次税率」这个实现细节写成了期望。重构没有改变任何对外的行为,测试却失败了;反过来,如果计算公式错了而调用次数没变,这个测试照样通过。这类测试在重构时制造噪音,在出错时又不报警。
verify 有它合适的用途:当调用本身就是要验证的行为时,比如「订单支付后要发送一封邮件」「重复请求不应该第二次扣款」。判断标准是:这个调用是不是需求的一部分,业务方会不会关心它。替身的更多问题见替身证明了什么。
五、准备数据:分开三种东西
测试准备的数据有三种来源,混在一起时,测试很难读懂,也很难在不同环境里运行:
- 静态参数:写在测试里、与场景直接相关的值,例如「10 公斤」。它们应该出现在测试正文或例子表里,读测试的人一眼能看到。
- 运行时生成的状态:测试过程中创建的数据,例如先创建的订单返回的 id、服务端生成的令牌。它们由前面的步骤产生、传给后面的步骤,不应该写死在测试里。
- 环境配置:服务地址、账号、超时时间。它们随环境变化,放在配置文件或环境变量里,不进入测试正文。
一个测试的正文里如果出现了 http://10.0.3.17:8080 或者一个写死的订单号,往往就是把这三者混在了一起。测试数据归谁、怎样隔离,见测试数据归谁。
六、检查清单
- 测试从一条用业务语言描述的行为出发,例子覆盖边界。
- 参数化测试的显示名说出例子的含义。
- 断言的形式与被检查的东西一致;不先算布尔值再
assertTrue。 - 检查同一结果的多个方面时用分组断言。
- 断言对外的行为;只在调用本身是需求时才校验调用。
- 静态参数写在测试里,运行时状态由前序步骤传递,环境配置放在测试之外。
配套实验
- codesphere-labs/testing/behavior-scenarios-assertions:JUnit 6.1.3、AssertJ 3.27.7、Mockito 5.24.0、JDK 25.0.4.1:四种断言的失败信息、逐条与分组断言、参数化显示名、重构后测行为与测调用次数的结果(验证记录)
参考资料