把大测试拆成小测试:覆盖的责任去了哪里
一个容器的测试类越写越长:每个用例都先登记一组组件,再从容器里取出对象检查注入结果。重构时把「注入规则」拆进单独的测试类,用桩代替容器,测试跑得快了,也更好读了。几周后有人改坏了限定符的登记逻辑,全部测试仍然是绿的。
拆分测试是常见的重构:大粒度的测试准备成本高、失败时难定位,按组件拆开后每个测试只关心一件事。但拆分和代码重构不一样的地方在于,测试的「行为」是它能发现哪些缺陷。拆分前一个测试同时承担了好几项责任,拆分后如果只是把断言挪到更小的测试里,原来顺带承担的那部分责任可能没人接手。
本文用一个最小容器、一条注入规则和一个异常响应器做实验:同一组测试在正确代码和两个故意引入的缺陷下各跑一次,看每个测试实际承担了什么;另外验证一个测试框架的细节:动态测试不会为每个子测试重新执行 @BeforeEach。实验见文末。
一、拆分之前,先列出每个测试承担的责任
原来的容器测试是这样的:在真实容器里登记两个限定的数据源,按注入规则构造报表,断言报表拿到了正确的两个数据源。它同时验证了两件事:
- 注入规则:报表向容器要的是
Source@raw和Source@daily。 - 容器装配:容器按限定符分别登记和查找,没有把两个绑定混在一起。
拆分后,注入规则的测试用桩 Context 记录被请求的键,不再需要真实容器:
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。
| 代码 | 装配测试 | 注入规则测试(桩) | 映射器失败(名不副实) | 映射器失败(真实) |
|---|---|---|---|---|
| 正确 | 通过 | 通过 | 通过 | 通过 |
| 丢掉限定符 | 失败 | 通过 | 通过 | 通过 |
| 映射器失败不回落 | 通过 | 通过 | 通过 | 错误 |
ignoreQualifier.ContainerTest.真实容器按限定符分别装配 raw 与 daily 失败:expected: <raw> but was: <daily>
ignoreQualifier.InjectionRuleTest.注入规则向 Context 要 Source@raw 与 Source@daily 通过丢掉限定符的缺陷,只有真实容器的装配测试能发现;注入规则测试在任何情况下都通过,因为桩永远按请求返回。如果拆分时把原来的容器测试全部删掉,只留下更快、更「干净」的桩测试,这个缺陷就无人发现。开头那个全绿的测试集就是这样形成的。
这不是说桩测试没有价值:它让注入规则的每种情况(构造器、字段、方法、非法声明)都能用很小的代价覆盖。合理的结构是规则测试承担规则的组合,少量真实装配测试承担接线,比例由风险决定,见这个风险该由哪一层测试发现。
三、名字说的和测试做的不一样
异常响应器先查找映射器,再调用映射器,任一步失败都回落到 500。有三个测试,其中一个是这样写的:
@DisplayName("映射器执行失败时回落到 500")
void mapperFailureMisnamed() {
ErrorResponder r = new ErrorResponder(e -> { throw new IllegalStateException("映射器坏了"); });
assertEquals(500, r.respond(new RuntimeException("x")));
}名字说的是映射器执行失败,桩却让查找失败了,和另一个「查找失败」的测试走的是同一条路径。变异结果说明了问题:
noMapperFallback.ErrorResponderTest.映射器执行失败时回落到 500(名不副实:实际让查找失败) 通过
noMapperFallback.ErrorResponderTest.映射器执行失败时回落到 500(真正让映射器抛异常) 错误:映射器坏了测试矩阵(「每个扩展点 × 每种失败」)是系统化列举失败场景的好办法,但矩阵里每一格的名字只是声明,覆盖要看桩在哪一步制造了失败。审查测试时,对照被测代码的分支逐个确认:这个测试让哪个调用失败了,它走的是哪条路径。变异检验能把这种问题机械地暴露出来;需要系统化时,可以使用专门的变异测试工具。
四、动态测试共享同一次准备
测试工厂(JUnit 的 @TestFactory)常用来生成矩阵式的测试:
@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);
}));
}子测试 a 通过
子测试 b 失败:expected: <[b]> but was: <[a, b]>
子测试 c 失败:expected: <[c]> but was: <[a, b, c]>
@BeforeEach 共执行 2 次(1 个普通测试 + 1 个测试工厂,工厂产生了 3 个动态子测试)@BeforeEach 是按测试方法执行的,一个测试工厂只算一个方法;它产生的动态子测试没有各自的生命周期回调。如果子测试修改了共享的夹具(列表、桩的配置、全局注册的单例),后面的子测试就会看到前面留下的状态,结果取决于执行顺序。
解决办法是让每个动态子测试在自己的 lambda 里创建所需的状态,或者改用参数化测试(@ParameterizedTest 的每次调用都有完整的生命周期)。共享全局状态(静态注册表、系统属性)的测试还要验证单独运行、打乱顺序运行时结果是否一致。测试数据的归属问题见测试数据归谁。
五、拆分测试的步骤
- 列出原测试承担的每项责任(规则、接线、错误翻译、资源清理……)。
- 为每项责任指定拆分后的测试,桩测试只承担它能证明的部分。
- 保留少量使用真实组件的测试承担接线,不因为「和规则测试重复」就删除。
- 对每项责任引入一个故意的缺陷,确认至少有一个测试失败;确认失败的是你预期的那个测试。
- 检查测试名和桩制造失败的位置是否一致。
- 动态测试和参数化测试分别检查夹具是否在子测试之间共享。
六、检查清单
- 拆分测试前写出责任映射表,拆分后每项责任都有归属。
- 用桩的测试证明「要了什么」,用真实组件的测试证明「接上了」。
- 用故意引入的缺陷验证测试真的会失败,并且是对的那个测试失败。
- 测试名与实际制造失败的位置一致。
- 动态子测试不依赖
@BeforeEach提供的新鲜状态。
配套实验
- codesphere-labs/testing/test-responsibility-migration:JDK 25.0.4.1、JUnit 6.1.3:同一组测试在正确代码与两个故意引入的缺陷下的结果,名不副实的测试,动态测试与 @BeforeEach(验证记录)
参考资料