Skip to content

从行为到场景到断言:写一个失败时能说清问题的测试 ​

构建红了,失败的测试叫 testCalculate3,信息是 expected: <true> but was: <false>。为了弄清楚是什么错了,得打开测试代码,读懂准备数据的二十行,再在调试器里跑一遍。另一个测试在一次纯粹的重构之后失败了,业务行为一点没变。

一个好的测试在两个时刻有用:失败的时候,它应该直接说出是哪条规则、在什么情况下、错成了什么样;代码重构的时候,只要行为没变,它就不应该失败。这两件事都取决于测试是从什么出发写的:从行为出发,经过具体的场景,落到能说清差异的断言;而不是从实现出发,检查代码内部做了哪些调用。

本文用 JUnit 6.1.3、AssertJ 3.27.7、Mockito 5.24.0,在一段带有几个错误的代码上比较不同的写法给出的失败信息,以及一次重构对两类测试的影响。实验见文末。

行为不满 10 公斤运费 10,10 公斤及以上运费 20场景(例子表)9 → 10、10 → 20、11 → 20每条带一句理由断言形式与被检查的东西一致分组断言一次报出全部差异同一个错误的失败信息assertTrue(contains):expected <true> but was <false>AssertJ:["apple","pear"] to contain ["banana"]参数化无显示名:[2] "10", "20" 失败带显示名:10 公斤 → 运费 20(恰好 10 公斤)失败计价重构(税率从每行查一次改为只查一次,结果相同)断言总额 30.80第一版通过,第二版通过verify(tax, times(3)).rate()第一版通过,第二版失败(实际 1 次)
图 1 · 实测(JUnit 6.1.3、AssertJ 3.27.7、Mockito 5.24.0):测试从一条业务规则出发,经过覆盖边界的例子,落到能说出差异的断言。带显示名的参数化测试直接指出是「恰好 10 公斤」这条例子错了;集合断言给出实际内容,assertTrue 只剩真假不符。计价重构后,断言总额的测试两版都通过,校验税率查询次数的测试在第二版失败。

一、从行为出发:先写下规则和例子 ​

测试的起点是一条用业务语言描述的行为:「不满 10 公斤运费 10 元,10 公斤及以上运费 20 元」。它不是「shippingFee 方法返回正确的值」,后者没有说出什么是正确的。

从行为到场景,是给规则找具体的例子,尤其是边界上的例子:9 公斤、10 公斤、11 公斤。每个例子有输入、期望和一句理由。这张例子表本身就是可以和业务方确认的东西,规则怎样用例子谈清楚,见用例子把规则谈清楚。

写成参数化测试时,把理由放进显示名:

java
@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); }
text
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 报告里只有这一行时,前者省掉了一次打开代码的过程。

二、断言:让失败信息说出差异 ​

同一个错误(购物车少了一件商品),四种断言写法:

text
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。

三、一次报出所有差异 ​

订单摘要有两个字段算错了:

text
逐条 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)会执行所有断言,再一起报告。检查同一个结果的多个方面时用分组断言;后一个断言依赖前一个成立(先断言不为空,再断言字段)时,仍然要逐条写。

四、测行为,不测内部调用 ​

计价从第一版重构到第二版:第一版每一行查一次税率,第二版只查一次,结果完全相同。

text
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。
  • 检查同一结果的多个方面时用分组断言。
  • 断言对外的行为;只在调用本身是需求时才校验调用。
  • 静态参数写在测试里,运行时状态由前序步骤传递,环境配置放在测试之外。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权