单元测试、JUnit 与 Mockito:验证行为而不绑定实现
用行为边界、参数化测试、Test Double、strict stubbing 与交互验证建立快速而不脆弱的单元证据。
JUnit 6 的 User Guide 以 Java 17 为运行基线并统一 Platform、Jupiter 与 Vintage 版本;Mockito 5 的 官方项目 提供 strict stubbing、mock/spy 与交互验证。
单元边界由决策速度和替换成本决定
单元不等于一个方法。订单定价可以把纯规则、折扣端口和结果对象作为一个快速单元;若每个私有方法都被单独测试,重构一次调用顺序就会造成大片无业务意义的失败。边界应让失败能定位到一条业务规则,同时把数据库、网络、系统时间和随机数隔离为显式端口。
测试名先写场景与结果,例如“余额等于冻结额时允许确认”,再准备最小输入、执行公开行为并观察返回值、状态变化或稳定协作。不要在 Arrange 阶段重建一套生产算法来计算 expected,否则实现和测试可能以相同错误同时通过。
边界表比堆 happy path 更接近规格
等价类与边界值覆盖 min-1、min、min+1、max-1、max、max+1;金额还要覆盖舍入、币种、零、负数和溢出;集合覆盖空、单个、重复、顺序和上限;状态机覆盖合法迁移、非法迁移、重复动作和终态。参数化测试让规则表成为可审查输入,而不是复制六个几乎相同方法。
Property-based 思路适合验证守恒、单调、幂等和往返关系,但随机生成必须输出 seed 与最小反例。示例测试仍负责业务语义;性质测试不能替代“哪个折扣优先”这类具体决定。
Mockito 只替换真正的协作边界
Mock 适合验证邮件网关只发送一次、支付端口收到稳定 operationId、失败时没有发布事件。值对象、集合、领域实体和自己拥有的纯函数用真实对象更清晰。深层 mock 链暴露生产对象图并把测试绑在 getter 顺序上,通常说明边界不对。
Stub 描述依赖返回,verify 描述必须发生的协议。只要最终状态足以证明行为,就不要再验证每个内部调用;只有外部副作用、顺序协议或禁止调用值得交互断言。strict stubbing 报告未使用或参数不匹配的桩,能发现测试准备与实际路径分叉;lenient 只能是局部、可解释例外。
时间、异常和取消也属于行为
将 Clock、随机源和 ID 生成器注入,测试使用固定实现;不要 Thread.sleep 等待“差不多完成”。同步异常同时断言类型、稳定错误码和无副作用;异步结果断言完成、超时、取消与重复回调。抢占式超时可能让代码在另一线程运行,需理解 ThreadLocal 与事务上下文是否丢失。
重构验收很简单:改变私有拆分、局部变量和调用顺序而业务行为不变,测试应继续通过;改变业务不变量时,至少一个测试先失败并能指出差异。
Test Double 的五种角色不能混用
Dummy 只填参数,Stub 提供间接输入,Spy 记录协作,Mock 预声明期望,Fake 用轻量实现保持一组真实规则。内存 Repository 若没有唯一约束、事务和版本语义,适合单元测试服务分支,却不能替代数据库集成测试。Fake 越聪明,越可能与真实实现产生第二套行为;它需要自己的契约套件,与真实适配器接受同一组输入。
Captor 适合观察发送到外部端口的不可变命令,但捕获后逐字段断言所有内部数据会放大耦合。只检查协议必须字段、稳定 operationId 和敏感数据未泄漏。Spy 调用真实方法,可能触发文件、时间或网络副作用;若必须大量 doReturn 绕过真实行为,通常应提取端口而不是继续 partial mock。
参数化、动态与嵌套测试各自解决什么
@ValueSource/@CsvSource 适合审查规则表,@MethodSource 适合复杂对象且应给每个参数清晰 display name。DynamicTest 可从外部规格生成用例,但发现阶段和执行阶段要分开,失败报告必须能定位具体 case。@Nested 表达同一状态下的场景树,不能用深层嵌套隐藏共享可变 Fixture。
JUnit Extension 管理时钟、临时资源和失败采集时,明确 before/after 顺序与异常清理;全局自动扩展会影响所有测试并降低可见性。迁移 JUnit 5 到 6 时固定发现的测试数量、tag、engine 和执行报告,防止 Vintage 或自定义 runner 用例静默消失。
红绿循环也要验证测试本身
回归测试先在缺陷版本稳定失败,再应用修复通过;若无法保留旧版本,可临时反转关键条件或注入已知坏实现证明断言敏感。直接在修复后写一个绿色测试,不能证明它能抓住原问题。对于异常、重试和交互,坏实现应产生不同的权威状态而不只是不同日志。
测试代码审查同样检查重复、命名、无效断言、过度 mock 和 Fixture 隐藏输入。测试比生产代码更频繁被阅读用于理解规则,晦涩的“一行链式魔法”会降低诊断速度。
先写证明对象,再选择测试层
测试替身越多,速度越快但与真实运行的距离越大;真实依赖越多,协议证据越强但状态和资源更难隔离。组合策略应让同一高风险结论至少由两种不同失败模式的证据支持,例如单元验证版本条件,真实数据库验证条件更新确实只影响一行。
失败必须保存成可重放状态
失败包至少包含测试 id、代码提交、依赖版本、seed、时钟、Locale、执行顺序、资源标识、容器日志、线程信息和业务见证。结果未知与产品断言失败分开统计;基础设施掉线不能自动归为产品缺陷,产品超时也不能全部甩给 CI。
可维护测试也需要性能、数据和安全边界
套件容量模型包含用例数、上下文/容器启动、数据初始化、并发 worker、日志与制品体积。并行度超过数据库连接、CPU 或容器资源只会增加尾延迟和 flaky;先量测各阶段临界路径,再决定共享、分片或下沉。
演进遵循“读取端先兼容、写入端后切换、旧证据归零再删除”。框架升级先固定当前发现数、执行顺序、上下文 key 和报告格式,再迁移版本;不能把升级造成的少跑测试误当提速。
用两个 Java 17 模型固定测试见证
模型不依赖测试框架或外部服务,只把边界输入、状态转换和期望见证压成确定输出;真实项目再用 JUnit、Mockito、Spring、容器或 WireMock 驱动同一不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/testing/unit-junit-mockito/BoundaryTableDemo.java examples/backend-development/testing/unit-junit-mockito/MockInteractionDemo.java
java -cp examples/backend-development/testing/unit-junit-mockito BoundaryTableDemo
java -cp examples/backend-development/testing/unit-junit-mockito MockInteractionDemocases=6 accepted=3 rejected=3 boundaryValues=[-1, 0, 1, 99, 100, 101] deterministic=true
externalCalls=1 retries=0 result=APPROVED strictStubs=true unusedStubs=0实际测试若改变输出,必须解释是业务规格、测试层、Fixture、调度还是门禁发生变化;禁止用放宽断言或无限重试吸收差异。
从失败现场回推测试体系缺口
测试断言可观察行为与协作契约,不复制私有实现步骤。围绕这条不变量记录测试 owner、独特证据、运行成本、失败率和最近一次真实发现。长期从不失败且与其他用例完全重复的测试需要合并;只在生产才失败的关键路径需要升级替身或增加真实边界。测试资产和生产代码一样接受删除、重构与版本治理。
团队门禁拒绝没有证据的绿色
代码评审要求说明为何选这一层、替身与真实系统差异、数据如何隔离、失败如何重放和哪个发布风险被覆盖。CI 报告展示未运行、跳过、隔离与重试,不允许只汇总“最终通过”。
测试资产的容量、升级与恢复
围绕“测试断言可观察行为与协作契约,不复制私有实现步骤”建立资产台账:用例 owner、被保护规则、依赖资源、平均/p95 耗时、失败率、独特证据和最后发现缺陷。套件增长时按临界路径和资源饱和定位,而不是只看用例总数。共享容器降低启动成本但提高污染半径,并行 worker 提升吞吐但会争用数据库、CPU 和端口;每次提速都用隔离失败率与反馈时间共同验收。
最终验收不以一次全绿结束:在相同 seed、调度、版本和资源边界下重复关键反例,确认失败能稳定出现、修复能稳定消除,且无关重构不会破坏测试。
