Java常用类库-35-用JUnit与AssertJ验证调用方责任
测试通过之前,先确认它能够失败
目录服务承诺返回商品列表快照。正常实现调用 ImmutableList.copyOf,测试检查列表包含 A。后来实现被改成直接返回输入列表,测试仍然通过,因为它从未修改原集合,也没有观察快照在源变化之后的内容。
测试执行成功只证明已有断言满足,不证明承诺已经覆盖。这个例子缺少的不是更多测试框架,而是能够区分快照与别名的输入动作:返回之后修改源列表,再检查结果是否变化。
本篇固定 JUnit Jupiter 5.13.4、AssertJ 3.27.6、Mockito 5.20.0。基础测试使用真实 Guava 与 Commons,在 JDK 8、21 运行;外部边界测试只在 JDK 21 运行。运行说明包含正常与受控突变命令,突变失败日志单独保存。
真实资源契约的对照见 第 34 篇;本篇聚焦测试如何发现调用方自身的错误。
按契约组织输入,而不是按方法数量组织测试
基础矩阵覆盖 null、空串、普通空格和制表符。JUnit 的 NullAndEmptySource 与 ValueSource 提供四次参数化调用,每次都调用真实 StringUtils.isBlank。这样报告能区分具体输入,而不是把多个条件藏在一个循环后只留下一个通过标记。
不过,四种输入并不能代表全部空白字符。NBSP、零宽字符与不同 Unicode 分类仍需要按业务规则补充,不能因为参数化语法简洁,就把少量数据称为完整覆盖。参数来源负责组织样本,样本是否有区分力仍由契约决定。
JUnit 5.13.4 参数化测试契约说明参数来源提供多组参数来调用同一测试方法。框架保证组织和生命周期规则,不会替应用判断哪些边界重要。一个没有断言的方法同样可能显示绿色。
测试名称应描述可观察承诺。例如 snapshotDoesNotFollowSourceChanges 说明源变化后快照不跟随;resourceClosesOnFailure 说明异常路径仍关闭资源。名称如果只叫 testMethod1,失败后仍要重新阅读整个方法才能知道哪个责任被破坏。
本章基础套件共七次执行,其中空白矩阵四次,快照、真实类库异常与失败关闭各一次。方法数量和执行次数不同,证据以 Surefire XML 为准;不能把四个参数误算成四套独立实现。
第一条可迁移模式是为每条承诺寻找能区分错误实现的动作。不可变需要尝试修改,快照需要修改源,资源责任需要注入失败,未知值策略需要提供未知输入。只测试常见成功值,往往无法区分满足契约与偶然返回相同结果。
AssertJ 的断言也有自己的语义
快照测试先创建只含 A 的 ArrayList,调用 snapshot,然后向源加入 B,最后断言结果 containsExactly(“A”)。这个断言同时约束元素、数量与顺序,能发现直接返回源列表造成的额外 B。
AssertJ 固定实现先将待检查 Iterable 收集为列表,检查内容等价,再检查元素顺序。固定上游测试分别覆盖内容不同、顺序不同和长度不同,说明 containsExactly 不等同于 contains。
若业务只要求集合内容而不要求顺序,应该采用对应的无序断言;若重复次数有意义,又不能简单把结果转换成 Set 再比较。断言本身也是契约编码,选择过强会导致无意义失败,选择过弱则可能放过回归。
另一个测试直接调用 ImmutableList.copyOf,传入含 null 的列表,断言抛 NullPointerException;随后尝试向 ImmutableList 添加元素,断言 UnsupportedOperationException。没有 mock 集合,因为此处需要证明的是调用方实际依赖的第三方行为。
异常断言也需要考虑稳定边界。异常类型常常属于 API 契约,而完整异常文案可能包含版本细节、路径或输入摘要。对自有业务错误可以检查稳定消息,对第三方异常则优先检查类型和必要属性,避免为了文字变化维护大量脆弱断言。
用受控突变证明快照断言有区分力
snapshot 实验函数保留一个仅供实验使用的系统属性开关。正常路径调用 ImmutableList.copyOf;当 chapter35.mutant=true 时,故意直接返回 input。两条路径在源尚未变化时都返回 A,只有后续修改才能区分。
独立运行突变命令后,JUnit 报告七次执行中一次失败,失败位置为 snapshotDoesNotFollowSourceChanges。AssertJ 显示实际结果包含 A、B,而预期只有 A。Maven 退出码非零,原始失败输出和 XML 保存到 evidence/35 的 mutant 文件。
这条红色结果是预期证据,不能混进最终正常套件的成功统计。完成突变观察后关闭开关,重新运行正常套件确认全部通过。交付的实验代码默认不会启用突变,RUN.md 明确区分正常命令与故意失败命令。
这里只验证一项人工注入的回归,不等于完成全面突变测试。它没有覆盖所有可能的错误实现,例如浅复制中元素仍可变、列表顺序被改变或异常策略被修改。每种承诺仍需要相应输入与动作,不能用一条成功杀死突变的结果推导全部测试充分。
突变开关只属于教学实验,不应原样进入生产实现。实际项目可以通过临时补丁或专门突变工具产生错误变体,并确保工作区恢复。重要的是记录哪条承诺被破坏、哪个测试发现,以及恢复后是否重新通过。
这种方法也适合审查既有测试。如果把资源关闭删除、把一次重试改成无界循环或把严格解析换成默认值,现有断言仍全部通过,就有证据说明测试缺少对应责任,而不是仅凭覆盖率百分比判断充分。
资源测试要观察真实的关闭动作
资源示例使用一个继承 ByteArrayInputStream 的小型输入流,覆盖 close 记录布尔状态。parseAndClose 拥有传入流,在 try-with-resources 中读取;遇到值一时抛 IOException(“bad record”)。测试同时断言异常消息和 closed=true。
这是一个受控测试替身,但它没有伪造第三方网络客户端的释放逻辑。被验证的是自有 parseAndClose 是否在失败路径调用所拥有资源的关闭方法;输入流只提供能够直接观察的接口行为。
如果方法的契约规定流由调用者拥有,则实现不应关闭它,测试也应反过来检查所有权。不能因为 try-with-resources 常见,就给每个接收 InputStream 的方法都加上关闭逻辑。所有权属于接口约定,需要在函数边界说清楚。
关闭标记也不等于所有底层资源已经正确释放。例如 HTTP 响应还涉及连接池状态,数据库游标还涉及事务连接。本章的简单流只验证方法调用责任;第 34 篇真实连接池实验观察 leased 数量,是另一层证据,二者不能互相替代。
第二条可迁移模式是把故障注入放在责任边界内。正常读取结束后检查关闭,容易漏掉异常路径;在读取后、返回前注入失败,才能检查清理是否与成功无关。文件、临时目录和锁的作用域也可以采用相同结构。
Mockito 只替代外部 Transport 边界
modern 示例定义应用自己的 Transport 接口,CatalogGateway 负责校验商品编号并调用 send。Mockito 创建这个接口的替身,让测试控制外部返回与失败。它没有 mock StringUtils、ImmutableList 或 HttpClient,因此不会用预设答案替代类库语义研究。
空白编号测试断言 IllegalArgumentException,并通过 verifyNoInteractions 确认请求没有到达 Transport。只检查异常不足以排除“先发送再发现参数错误”的实现;调用次数观察补上了这个差异。
传输失败测试让 send(“A”) 抛同一个 IOException 实例,断言 gateway 传播该实例,并验证恰好调用一次、没有更多交互。这证明当前 gateway 没有擅自重试,不代表外部网络绝不会重试,也不证明真实服务端没有收到请求。
成功测试使用具有角色含义的编号 shop:A,确认 Transport 收到精确字符串并返回 payload。若 gateway 对编号做了意外截取或大小写转换,精确参数验证可以发现。测试数据比单纯使用字符串 x 更能暴露格式变化。
verifyNoMoreInteractions 应围绕明确契约使用。若业务只要求最终结果,不限制内部读取次数,过度验证每一次内部调用会把测试绑在实现结构上;重构即使保持行为也会失败。本例调用次数涉及外部副作用和重试责任,因此值得断言。
mock 的边界同时决定证据边界。替身可以证明 gateway 对依赖发出了什么请求、如何处理依赖返回,却不能证明 Transport 的真实协议、连接释放、鉴权或序列化。那些行为需要真实库与本机替身服务,正如第 32、34 篇的实验。
Mockito 版本与运行时单独冻结
Mockito 5.20.0 放在 modern 工程,按 Java 17 编译,在实际 JDK 21 执行;没有为了让 Java 8 套件通过而悄悄替换为旧 Mockito。基础篇的七次测试不依赖 Mockito,两个运行范围在报告中分别列出。
Mockito 固定文档源码说明现代 JVM 下显式配置 instrumentation 的方法。本实验通过启动参数把固定 mockito-core jar 作为 javaagent,避免依赖运行中的自动附加路径。
这项配置解决测试框架所需的运行条件,不改变应用 Transport 的业务契约。缺少 agent、版本不兼容或测试引擎未启动,应当作为环境失败报告,不能改成跳过断言后仍称测试通过。
AssertJ 与 JUnit 可以独立使用;Mockito 也不是每个测试的必要依赖。真实、快速、可控的对象通常直接构造即可。只有外部边界难以控制,或者需要验证不应发生的调用时,替身才提供明确价值。
将证据写成可复跑的判断
正常测试报告记录环境、测试名称、次数、失败和跳过;突变报告记录预期失败的位置与非零退出。两组报告共同说明实验既能通过正确路径,也能识别指定错误路径。仅保存终端最后一行 BUILD SUCCESS,无法复核具体哪个契约被执行。
对于故意失败的实验,还应防止“命令因其他原因失败”被误当成成功发现突变。这里核对 XML 中唯一 failure 的测试名,以及输出中多出的 B;编译失败、依赖下载失败或测试未发现,都不满足突变验收条件。
Chapter35Test 双 JDK 各七次通过,Chapter35ModernTest 在 JDK 21 三次通过;受控突变单独产生一次预期失败。未执行全量自动突变分析、并发压测或真实外部服务调用,不能由此宣称生产系统整体正确。
反例题:把 snapshot 的返回结果转换成 Set,再断言包含 A,能否发现额外 B?如果只检查 contains(A),仍然发现不了。断言需要限制完整内容,或者直接检查快照在源变化之后保持原值。
改动练习:把资源示例中的 try-with-resources 临时改成只在成功时关闭,运行失败路径测试,确认 closed 断言失败。随后恢复实现并重跑,再新增“调用方拥有输入流”的另一个接口,分别证明两个所有权契约。
| 责任 | 区分错误实现的动作 | 证据 |
|---|---|---|
| 输入策略 | null、empty、blank 分别输入 | 参数化执行记录 |
| 快照 | 返回后修改源 | containsExactly与突变红条 |
| 异常契约 | 真实类库非法输入 | 异常类型断言 |
| 资源所有权 | 清理前注入失败 | close状态 |
| 外部调用 | 拒绝、成功、失败三路径 | Transport参数与次数 |
