覆盖率、测试金字塔与 CI:从执行报告判断测试效果
运费规则“满 100 元免运费”可以用 0 元、200 元和负数三个输入覆盖全部分支。把比较符从“大于等于”改成“大于”,这三个测试仍会成功,恰好 100 元的订单却收费错误。覆盖报告中的绿色说明某些代码执行过,断言能否发现错误还要进一步检查。
从覆盖数据读出实际执行范围
JaCoCo 记录了什么
JaCoCo 在字节码执行路径中插入 probe,测试运行时记录哪些 probe 被经过,再将执行数据与原始 class 文件关联,生成指令、分支和源码行报告。运行时采集文件通常是 jacoco.exec,它需要配合本次编译的 class 才能解释。
源代码 → 编译后的 class
│
├── 测试 JVM 加载类 → agent 插桩 → probe 执行记录 → jacoco.exec
│
└── 原始 class + 调试信息 + exec → HTML / XML / CSV 报告覆盖率指标有不同含义。指令覆盖统计字节码指令;分支覆盖统计 if/switch 等控制流分支,异常处理不按同一分支计数;行覆盖借助调试信息映射到源码,一行包含多个表达式时可能只覆盖一部分。构造器和静态初始化也可能进入方法计数。JaCoCo Coverage Counters
因此,行覆盖 100% 时仍要查看分支、输入与断言。遗漏异常场景也未必表现为遗漏一个 JaCoCo 分支。复杂表达式、多条件组合、状态迁移和外部协议,应按业务规则另外列出关键例子。
使用同一份字节码生成报告
JaCoCo 用 class ID 将运行数据与分析对象对应,当前实现从原始 class 字节计算标识。相同类名重新编译后,编译器版本、选项或字节码处理变化都可能产生不同内容。拿前一轮 exec 配合后一轮 class,会导致无法匹配或看似未覆盖。JaCoCo Class IDs
正确的保存单位应包含本轮源码版本、编译产物、执行数据和报告。覆盖异常时先看 HTML 的 Sessions,再确认报告使用的是哪份 class,最后检查其他 agent 是否修改了类。不要先通过删除异常类的统计范围让比例恢复。
对比弱测试、边界表与变异结果
运行可比较的两套测试
下载 覆盖率与 CI 实验工程。Java 编译目标 17,支持 Java 17/25 执行;JUnit 6.0.3、JaCoCo 0.8.15、PIT 1.30.0、PIT JUnit 插件 1.2.3,Maven 3.9.12。
src/main/java/example/testing/ShippingFee.java
src/test/java/example/testing/
├── WeakFeeTest 0、20000、-1:覆盖分支,遗漏免运费临界值
├── StrongFeeTest 阈值两侧与恰好阈值,加负数拒绝
├── ShippingHttpIT 真实 HTTP 成功与拒绝
└── OnePathCase 单独选择的低覆盖实验
ci/
├── verify.sh 执行强测试、保存日志与报告
└── CheckReports.java 读取实际 JUnit/JaCoCo/PIT XML在 Linux 宿主准备 Docker CLI 和 unzip,普通用户有权访问开发 daemon:
unzip testing-quality-lab.zip
cd testing-quality
mkdir -p .m2
LAB_DIR="$(pwd -P)"
MAVEN_IMAGE='maven:3.9.12-eclipse-temurin-25'
docker version
docker pull "$MAVEN_IMAGE"
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 \
clean verify org.pitest:pitest-maven:1.30.0:mutationCoverageMaven 按宿主 UID/GID 写入目录,测试不需要 Docker socket。已有本机 JDK/Maven 时可直接执行相同目标。首次运行须下载构建插件和 PIT 执行依赖,内网环境先准备组织维护的 mirror 与缓存。
默认选择 WeakFeeTest 的 3 次单元测试,再执行 ShippingHttpIT 的 2 次集成测试。JaCoCo 只采集这轮单元测试;HTTP 集成测试结果单独报告,不混入弱/强单元测试的覆盖对比。
打开本地 target/site/jacoco/index.html 查看覆盖报告,打开 target/pit-reports/index.html 查看变异详情。CSV 与 XML 适合机器处理,HTML 适合定位到具体源码行。
全分支覆盖仍留下一个存活变异
ShippingFee 的核心代码为:
if (subtotalCents < 0) throw new IllegalArgumentException("NEGATIVE_SUBTOTAL");
return subtotalCents >= 10_000 ? 0 : 600;弱测试检查 0 收 600、20000 收 0、-1 抛异常。JaCoCo 报告 20 条指令、4 条分支和 3 行可执行源码全部覆盖。PIT 在该固定类与默认变异集合上生成 5 个变异,其中 4 个被杀死,1 个存活:免运费比较符的边界变化。
PIT 会先分析测试覆盖,选择相关测试,再对字节码中的小变化运行测试。改变比较边界、反转条件或替换返回值,都可能模拟常见错误。若某个变化使已有测试失败,就被标记为 KILLED;测试仍成功则标记为 SURVIVED。PIT Maven 使用说明
进入存活项可看到 changed conditional boundary,对应 subtotalCents >= 10_000。PIT 没有修改工作区中的 Java 源文件,变化发生在它用于执行测试的变异字节码中。
增加有区分度的输入
强测试增加 9999、10000 和 10001,特别是恰好等于阈值的行:
@ParameterizedTest
@CsvSource({"0,600", "1,600", "9999,600", "10000,0", "10001,0"})
void thresholdTable(long input, long expected) {
assertEquals(expected, new ShippingFee().cents(input));
}沿用前面的容器命令,在 clean verify 前增加 -Pstrong,保持后面的 mutationCoverage 目标。这个 profile 选择 StrongFeeTest,并将本示例的变异阈值设为 100。预期单元测试为 6 次,HTTP 集成测试仍为 2 次,5 个变异全部被杀死。
| 结果 | WeakFeeTest | StrongFeeTest |
|---|---|---|
| 单元执行次数 | 3 | 6 |
| JaCoCo 分支 | 4/4 | 4/4 |
| PIT 杀死变异 | 4/5 | 5/5 |
| 免运费临界值错误 | 存活 | 被 10000,0 发现 |
这个差异来自更有区分度的输入与断言,覆盖比例本身没有变化。100% 只作为这段小函数的实验条件;大型项目需要按规则风险、修改频率和维护成本选择测试范围,不能把这个阈值直接套到所有模块。
解释不同的 PIT 状态
SURVIVED 需要阅读变化后实际行为,可能是缺少断言、遗漏输入,也可能属于业务上不可区分的等价变化。NO_COVERAGE 表示没有相关执行覆盖;TIMED_OUT 表示变异触发执行超时,需结合原始测试耗时与变化内容分析。无法正常执行的变异和内存错误也应与“正确杀死”分开。
PIT 会启动用于分析和执行的子进程,运行规模通常高于普通单元测试。优先用于金额计算、权限判断、状态迁移等关键规则,再考虑变更范围与周期性全量执行。完整变异算子见 PIT Mutators。
JUnit 的 major 升级需要同时检查 PIT 插件。工程固定能共同运行的版本组合,升级后应重新检查测试发现和实际变异结果,不只确认 Maven 能解析依赖。PIT JUnit 5 插件
按测试成本与风险组织持续反馈
测试金字塔是一种执行组合
纯规则测试启动快,适合覆盖大量输入;真实数据库、HTTP 和消息测试检查组件间协作;端到端测试穿过更多部署组件,准备与定位成本通常更高。测试金字塔用较多低成本检查支撑少量关键跨系统路径,但数量比例要根据系统风险调整。
订单系统可以把金额阈值放在单元层,把数据库唯一约束与提交放在集成层,把消费者/提供者格式放在契约层,再用少量端到端流程验证身份、部署配置和主要交易路径。对于数据库逻辑占比高的系统,真实数据库测试可能比大量 Repository mock 更直接。
不要在每一层复制全部输入矩阵。更实用的分配是让各层检查它能独立发现的故障,并保留少量跨层关键路径。金字塔原始讨论见 Martin Fowler:Test Pyramid。
安全、兼容、迁移和故障恢复可以横跨这些层次。一个高覆盖的金额函数不会回答生产账号是否越权,也不会回答旧 schema 能否升级,这些风险要有对应的测试对象。
Surefire 与 Failsafe 的执行位置
Maven 生命周期中的常用关系为:
compile → test-compile → test
└── Surefire:*Test 等默认命名
package → pre-integration-test → integration-test → post-integration-test → verify
│ │
└── Failsafe:*IT 等 └── 判定集成测试结果Surefire 默认测试发现与选择见 Surefire Inclusions。Failsafe 在 integration-test 执行、在 verify 判断结果,让 post-integration-test 有机会完成清理;只调用中间阶段可能绕开最终失败判定。Failsafe 生命周期
工程的 HTTP 测试真实启动 JDK HttpServer 与客户端,在 afterEach 关闭服务器和 executor。它由 Failsafe 执行,并分别验证 200/600 与负数的 400 拒绝。测试类名是执行分组约定,实际是否集成仍由其启动和调用对象决定。
-Dtest 与 -Dit.test 分别用于相应插件的测试选择。skipTests、maven.test.skip、tag、includes/excludes 和 profile 都可能改变执行集合。需要将本轮数量与预期任务对照,避免某个阶段没有测试却仍被当成已经完成。
agent 参数要明确属于哪个 JVM
JaCoCo prepare-agent 默认写入 Maven 的 argLine 属性;Surefire/Failsafe 的同名配置可能都读取它。若希望分别统计单元与集成数据,应使用不同属性与目标文件。
本工程将单元 agent 放入 unitArgLine,只有 Surefire 使用它:
<execution>
<id>unit-agent</id>
<goals><goal>prepare-agent</goal></goals>
<configuration><propertyName>unitArgLine</propertyName></configuration>
</execution><!-- Surefire -->
<configuration>
<failIfNoTests>true</failIfNoTests>
<argLine>@{unitArgLine}</argLine>
</configuration>POM 将 unitArgLine 初始化为空,Failsafe 不引用该属性。若沿用默认全局 argLine,仅给 Failsafe 写一个空标签,仍可能从插件属性绑定中取得 agent,导致两类执行数据混在一起。独立属性能让配置意图更清楚。
接入 Mockito agent 时,保留 JaCoCo 的 late evaluation 参数,并在同一测试 JVM 启动参数中追加 Mockito agent。设置 forkCount 为 0 会绕过正常 fork 的 javaagent 启动方式,造成未采集覆盖。目标与分层采集例子见 JaCoCo Maven Plugin。
需要整体覆盖时,可为集成测试配置 prepare-agent-integration 与独立 exec,最后使用 merge 或多模块 report-aggregate。报告应注明合并了哪些测试和模块,不能把各模块百分比简单做算术平均。
验证失败路径,并保存可解释的报告
覆盖不足应当在哪里失败
工程要求运费类的分支覆盖达到 1.0。OnePathCase 只执行金额 20000 分对应的免运费路径,在同一个 Linux 环境运行:
set +e
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 \
-Dtest=OnePathCase clean verify > coverage-failure.log 2>&1
status=$?
set -e
test "$status" -ne 0
grep -F 'branches covered ratio' coverage-failure.log单元断言本身成功,两个 HTTP 集成测试也可以成功,构建最终在 JaCoCo check 拒绝低覆盖。这同时检查了 HTTP 执行没有意外混入单元覆盖数据。
另有两种可单独选择的负例。已配置本机 Maven/JDK 时执行下列命令;容器方式替换原命令的 Maven 参数:
# 预期无匹配测试,非零退出
mvn -B -ntp -Dtest=MissingCase test
# 预期 HTTP IT 的实际 600 与错误预期 999 不符,verify 非零退出
mvn -B -ntp -Dhttp.expected=999 verify分别检查 No tests matching pattern 与 expected: <999> but was: <600>,不能只检查退出非零。修复选择或移除错误属性后,再运行强测试正常路径,重新生成报告。
可复用的 CI 命令入口
包内 ci/verify.sh 执行强单元测试、HTTP 集成测试、JaCoCo 与 PIT,并在退出时保存已有报告。它使用 Bash 的 pipefail,Maven 经过 tee 输出日志后,失败状态仍能传回调用者。
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
bash ci/verify.sh -Duser.home=/tmp -Dmaven.repo.local=/m2脚本先检查本轮 exec/XML 存在,再用 CheckReports.java 读取实际 XML,确认强单元测试 6 次、集成 2 次、分支 4/4、变异 5/5,且没有失败、错误或跳过。数字来自该固定实验的规模,修改测试集合时应同步审查相应预期。
CI 平台接入时,将 reports 目录设置为成功或失败都保留的 job artifact,并关联此次源码提交与构建环境。平台负责调度、凭据和产物保存;这份脚本在当前工作目录运行,不依赖某个 CI 产品的专有 API。
缓存只用于缩短下载时间,不能缓存上一轮测试结果代替本轮执行。使用依赖锁定、镜像版本和可追溯构建标识,也比引用一个不断变化的 latest 更容易解释两次报告差异。
报告异常的处理顺序
| 现象 | 优先检查 | 下一步 |
|---|---|---|
| 构建绿色但测试数量为 0 | 插件发现规则、选择参数、profile、skip | 明确选择已知类,确认真实执行和报告 |
| 没有 jacoco.exec | 测试 JVM argLine、fork、agent 文件、提前退出 | 修正采集后重跑;不要把缺失报告记为 0% 或通过 |
| 执行过却显示未覆盖 | exec 与 class 是否同轮、其他 agent 改写 | 查看 Sessions,保存并使用相同字节码 |
| unit 覆盖意外升高 | Failsafe 是否继承同一个 agent/exec | 分离属性与目标文件,再对照执行范围 |
| PIT 没找到相关测试 | JUnit 插件、目标类/测试筛选、基线是否正常 | 先跑普通测试,再检查 mutationCoverage 选择 |
| 存活变异很多 | 具体变异、输入、断言与业务等价性 | 先修关键规则,不用批量排除追求分数 |
| 只有重试后成功 | 首次失败、外部依赖和线程资源 | 保存每次结果,按 Flaky Test 方法定位 |
| 缓存后与冷启动不同 | 实际依赖、生成文件、旧 exec/Pact/报告 | 从干净目标目录重建并比较产物 |
覆盖率用来寻找未执行的代码,变异用来检查某些小错误是否能被测试发现,集成与契约测试检查真实协作,CI 则让这些任务在明确版本上重复执行。将每份报告放回其实际测试对象,才方便判断当前改动还缺哪一种验证。
权威资料与规范地址
覆盖与变异
- JaCoCo Counters:https://www.jacoco.org/jacoco/trunk/doc/counters.html
- JaCoCo Class IDs:https://www.jacoco.org/jacoco/trunk/doc/classids.html
- JaCoCo Maven Plugin:https://www.jacoco.org/jacoco/trunk/doc/maven.html
- PIT Maven:https://pitest.org/quickstart/maven/
- PIT Mutators:https://pitest.org/quickstart/mutators/
- PIT JUnit 插件:https://github.com/pitest/pitest-junit5-plugin
