Skip to content

把大测试拆成小测试:覆盖的责任去了哪里 ​

一个容器的测试类越写越长:每个用例都先登记一组组件,再从容器里取出对象检查注入结果。重构时把「注入规则」拆进单独的测试类,用桩代替容器,测试跑得快了,也更好读了。几周后有人改坏了限定符的登记逻辑,全部测试仍然是绿的。

拆分测试是常见的重构:大粒度的测试准备成本高、失败时难定位,按组件拆开后每个测试只关心一件事。但拆分和代码重构不一样的地方在于,测试的「行为」是它能发现哪些缺陷。拆分前一个测试同时承担了好几项责任,拆分后如果只是把断言挪到更小的测试里,原来顺带承担的那部分责任可能没人接手。

本文用一个最小容器、一条注入规则和一个异常响应器做实验:同一组测试在正确代码和两个故意引入的缺陷下各跑一次,看每个测试实际承担了什么;另外验证一个测试框架的细节:动态测试不会为每个子测试重新执行 @BeforeEach。实验见文末。

装配(真实容器)规则(桩)名不副实映射器失败正确代码通过通过通过通过丢掉限定符失败通过通过通过映射器不回落通过通过通过错误每项责任至少一个测试能让它失败动态测试:@BeforeEach 按测试方法执行,测试工厂只算一个方法子测试 a:[a]子测试 b:[a, b]子测试 c:[a, b, c]拆分测试前写出责任映射表;用桩证明「要了什么」,用真实组件证明「接上了」;测试名要和桩制造失败的位置一致;动态子测试在自己的 lambda 里创建状态
图 1 · 实测(JUnit 6.1.3、JDK 25.0.4.1):丢掉限定符的缺陷只有真实容器的装配测试发现,用桩的注入规则测试始终通过;映射器失败不回落的缺陷只有真正让映射器抛异常的测试发现,名字说映射器失败、实际让查找失败的那个测试始终通过。三个动态子测试共享工厂方法那一次的 @BeforeEach,后两个看到了前面留下的数据。

一、拆分之前,先列出每个测试承担的责任 ​

原来的容器测试是这样的:在真实容器里登记两个限定的数据源,按注入规则构造报表,断言报表拿到了正确的两个数据源。它同时验证了两件事:

  1. 注入规则:报表向容器要的是 Source@raw 和 Source@daily。
  2. 容器装配:容器按限定符分别登记和查找,没有把两个绑定混在一起。

拆分后,注入规则的测试用桩 Context 记录被请求的键,不再需要真实容器:

java
Context stub = (type, q) -> { asked.add(type.getSimpleName() + "@" + q); return (Source) () -> q; };
Report.inject(stub);
assertEquals(List.of("Source@raw", "Source@daily"), asked);

这个测试只能证明第 1 项。第 2 项必须由某个使用真实容器的测试承担。拆分时值得写一张表,把「拆分前的测试 → 它承担的责任 → 拆分后由哪个测试承担」逐项列出来,每项责任都要有新的归属。

二、用变异检验确认责任真的有人承担 ​

表写完了,怎么知道它是对的?最直接的办法是故意把代码改坏,看哪些测试会失败。实验用系统属性开关引入两个缺陷:登记时丢掉限定符、映射器自身抛异常时不再回落到 500。

代码装配测试注入规则测试(桩)映射器失败(名不副实)映射器失败(真实)
正确通过通过通过通过
丢掉限定符失败通过通过通过
映射器失败不回落通过通过通过错误
text
ignoreQualifier.ContainerTest.真实容器按限定符分别装配 raw 与 daily	失败:expected: <raw> but was: <daily>
ignoreQualifier.InjectionRuleTest.注入规则向 Context 要 Source@raw 与 Source@daily	通过

丢掉限定符的缺陷,只有真实容器的装配测试能发现;注入规则测试在任何情况下都通过,因为桩永远按请求返回。如果拆分时把原来的容器测试全部删掉,只留下更快、更「干净」的桩测试,这个缺陷就无人发现。开头那个全绿的测试集就是这样形成的。

这不是说桩测试没有价值:它让注入规则的每种情况(构造器、字段、方法、非法声明)都能用很小的代价覆盖。合理的结构是规则测试承担规则的组合,少量真实装配测试承担接线,比例由风险决定,见这个风险该由哪一层测试发现。

三、名字说的和测试做的不一样 ​

异常响应器先查找映射器,再调用映射器,任一步失败都回落到 500。有三个测试,其中一个是这样写的:

java
@DisplayName("映射器执行失败时回落到 500")
void mapperFailureMisnamed() {
    ErrorResponder r = new ErrorResponder(e -> { throw new IllegalStateException("映射器坏了"); });
    assertEquals(500, r.respond(new RuntimeException("x")));
}

名字说的是映射器执行失败,桩却让查找失败了,和另一个「查找失败」的测试走的是同一条路径。变异结果说明了问题:

text
noMapperFallback.ErrorResponderTest.映射器执行失败时回落到 500(名不副实:实际让查找失败)	通过
noMapperFallback.ErrorResponderTest.映射器执行失败时回落到 500(真正让映射器抛异常)	错误:映射器坏了

测试矩阵(「每个扩展点 × 每种失败」)是系统化列举失败场景的好办法,但矩阵里每一格的名字只是声明,覆盖要看桩在哪一步制造了失败。审查测试时,对照被测代码的分支逐个确认:这个测试让哪个调用失败了,它走的是哪条路径。变异检验能把这种问题机械地暴露出来;需要系统化时,可以使用专门的变异测试工具。

四、动态测试共享同一次准备 ​

测试工厂(JUnit 的 @TestFactory)常用来生成矩阵式的测试:

java
@BeforeEach void freshList() { received = new ArrayList<>(); }

@TestFactory
Stream<DynamicTest> eachExpectsFreshState() {
    return Stream.of("a", "b", "c").map(x -> dynamicTest("子测试 " + x, () -> {
        received.add(x);
        assertEquals(List.of(x), received);
    }));
}
text
子测试 a	通过
子测试 b	失败:expected: <[b]> but was: <[a, b]>
子测试 c	失败:expected: <[c]> but was: <[a, b, c]>
@BeforeEach 共执行 2 次(1 个普通测试 + 1 个测试工厂,工厂产生了 3 个动态子测试)

@BeforeEach 是按测试方法执行的,一个测试工厂只算一个方法;它产生的动态子测试没有各自的生命周期回调。如果子测试修改了共享的夹具(列表、桩的配置、全局注册的单例),后面的子测试就会看到前面留下的状态,结果取决于执行顺序。

解决办法是让每个动态子测试在自己的 lambda 里创建所需的状态,或者改用参数化测试(@ParameterizedTest 的每次调用都有完整的生命周期)。共享全局状态(静态注册表、系统属性)的测试还要验证单独运行、打乱顺序运行时结果是否一致。测试数据的归属问题见测试数据归谁。

五、拆分测试的步骤 ​

  1. 列出原测试承担的每项责任(规则、接线、错误翻译、资源清理……)。
  2. 为每项责任指定拆分后的测试,桩测试只承担它能证明的部分。
  3. 保留少量使用真实组件的测试承担接线,不因为「和规则测试重复」就删除。
  4. 对每项责任引入一个故意的缺陷,确认至少有一个测试失败;确认失败的是你预期的那个测试。
  5. 检查测试名和桩制造失败的位置是否一致。
  6. 动态测试和参数化测试分别检查夹具是否在子测试之间共享。

六、检查清单 ​

  • 拆分测试前写出责任映射表,拆分后每项责任都有归属。
  • 用桩的测试证明「要了什么」,用真实组件的测试证明「接上了」。
  • 用故意引入的缺陷验证测试真的会失败,并且是对的那个测试失败。
  • 测试名与实际制造失败的位置一致。
  • 动态子测试不依赖 @BeforeEach 提供的新鲜状态。

配套实验

参考资料

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