CClara轻量论坛系统

Java 单元测试最佳实践:分层测试与覆盖率

晁铭
晁铭 正式会员正式会员认证极客认证极客
发布于 2026-11-06 21:00 ·7 浏览 ·12 回复
内容摘要

Java单元测试应按单元、集成、契约和端到端分层,分别控制耗时与运行阶段,并通过Maven依赖、Surefire/Failsafe命名约定以及只mock边界、注入Clock、按行为命名等做法保证单元测试快而准,同时用JaCoCo覆盖率设置合并请求硬性门槛。

学完这篇,你能把项目里的测试按「单元—集成—端到端」分层搭起来,并用 JaCoCo 把覆盖率变成一条能卡住合并请求的硬性门槛。

第一步:先把“测试”这个词拆开

很多项目的测试目录烂掉,是因为所有测试都叫“单元测试”:有启动 Spring 容器的、有连真实数据库的、有断言 HTTP 状态码的,跑一次要八分钟。先按职责分层:

层测什么工具单次耗时目标
单元测试纯业务逻辑、算法、校验JUnit 5 + Mockito + AssertJ毫秒级
集成测试仓储、SQL、事务、序列化Spring Boot Test + Testcontainers秒级
契约测试对外接口字段不漂移Spring Cloud Contract / 手写 JSON Schema 断言秒级
端到端关键链路(下单、支付回调)RestAssured / Playwright分钟级

分层不是为好看,是为了让你在本地能几秒钟跑完单元测试、提交前能跑完全部集成测试,而端到端只在 CI 的合并后阶段跑。

注意:不要用 *Test 后缀混装所有层。约定 *Test 只放单元测试,*IT 放集成测试,否则 mvn test 会把慢测试一起拖进来,团队很快就会开始跳过测试。

第二步:把骨架和依赖配好

Maven 里加三块依赖,版本交给 dependencyManagement 统一管:

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <scope>test</scope>
</dependency>

Surefire 插件让 mvn test 只捡单元测试:

<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <includes><include>**/*Test.java</include></includes>
  </configuration>
</plugin>
<plugin>
  <artifactId>maven-failsafe-plugin</artifactId>
  <configuration>
    <includes><include>**/*IT.java</include></includes>
  </configuration>
</plugin>

常用命令记三个:mvn test(只跑单元测试)、mvn -Dtest=OrderServiceTest test(跑单个类)、mvn verify(单元测试和集成测试一起跑)。

第三步:单元测试要“快”和“准”

三个习惯能解决八成问题。

只 mock 边界,不 mock 数据。 仓储、第三方 SDK、时间源、随机源是边界,DTO、实体、List 直接 new 出来用。when(repo.findById(1L)).thenReturn(Optional.of(new Order(...))) 这样写就够了,不必对 DTO 做 mock()。

固定不确定性。 时间用注入的 Clock,不要在业务代码里写 LocalDateTime.now();随机数抽成接口。否则测试会在某个月末、某个时区里偶发失败。

按行为命名。 shouldRejectWhenBalanceInsufficient() 比 testPay2() 有用得多。断言的写法用 AssertJ:

assertThatThrownBy(() -> service.pay(orderId, new BigDecimal("9999")))
    .isInstanceOf(InsufficientBalanceException.class)
    .hasMessageContaining("余额不足");

注意:verify(mock) 校验调用次数属于“实现细节断言”,改一行内部实现就红。除非是在测幂等、测缓存命中这类行为契约,别滥用。

第四步:集成测试用 Testcontainers,别用 H2

H2 能跑起来的 SQL,在 MySQL 上未必跑得起来(ON DUPLICATE KEY、JSON 函数、排序规则)。用 Testcontainers 起真库:

@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
    r.add("spring.datasource.url", mysql::getJdbcUrl);
    r.add("spring.datasource.username", mysql::getUsername);
    r.add("spring.datasource.password", mysql::getPassword);
}

容器用 static 字段声明,一个测试类只启动一次,别每个方法都起容器。

注意:Testcontainers 依赖本机 Docker。CI 上要么用 DinD,要么挂载宿主机的 /var/run/docker.sock;本地 Windows 记得把 Docker Desktop 跑起来,不然 mvn verify 直接卡在拉镜像。

第五步:覆盖率接进构建,而不是看报告感动自己

JaCoCo 的配置入口在 pom.xml 的 <build><plugins>:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.12</version>
  <executions>
    <execution>
      <goals><goal>prepare-agent</goal></goals>
    </execution>
    <execution>
      <id>check</id>
      <phase>verify</phase>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit>
                <counter>BRANCH</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.70</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>

跑 mvn verify 后报告在 target/site/jacoco/index.html,用浏览器直接打开。

三个配置细节决定这条门禁能不能长期活下去:

  1. 门禁看分支覆盖,不看行覆盖。 行覆盖 80% 往往意味着大量 if 的 else 分支根本没走过。
  2. 排除不该算的东西:DTO、*Application、自动生成的 MapStruct/QueryDSL 代码,用 <excludes> 加包路径通配。
  3. 门禁设在“当前值往上一点”,比如现在 68% 就先设 70%,别一上来写 90%——那只会逼出一个 // jacoco skip 满天飞的仓库。

注意:覆盖率只证明“代码被执行过”,不证明“断言写对了”。想验证断言质量,加一层变异测试:mvn org.pitest:pitest-maven:mutationCoverage,看有多少变异体被杀掉。PIT 慢,放在夜间任务里跑就够。

第六步:CI 里串起来

流水线的三段跑法:

mvn -q test          # 单元测试,1 分钟内
mvn -q verify        # 集成测试 + JaCoCo 门禁

失败就阻断合并。覆盖率报告作为构建产物上传,方便回看趋势——一次从 72% 掉到 65% 的 PR,比一百条口头规范都管用。

小结

  • 测试先分层:*Test 单元、*IT 集成、端到端单独跑,靠 Surefire/Failsafe 的 includes 隔开。
  • 单元测试只 mock 边界(仓储、外部 SDK、时间源),DTO 直接 new。
  • 集成测试用 Testcontainers 起真库,容器声明成 static 复用。
  • 覆盖率门禁看 BRANCH 而不是 LINE,排除 DTO 和生成代码,阈值小幅递增。
  • 覆盖率证明不了断言质量,用 PIT 变异测试补一刀。
  • 门禁落到 CI 的 verify 阶段,阻断合并,报告留档看趋势。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1103.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

不语
不语 正式会员正式会员认证极客认证极客 1楼 2026-11-06 21:10

这套分层思路是对的,但真正决定它能不能长期跑下去的,是两件事:命名约定要能被构建工具强制执行(而不是靠自觉),覆盖率门禁只限制增量、不限制存量。

先补一下骨架的细节:Failsafe 默认绑定 integration-test 和 verify 两个阶段,所以 mvn test 只跑 *Test,集成测试要靠 mvn verify;记得把 failsafe 的 integration-test、verify 两个执行目标显式绑定,否则很容易出现“我本地跑了但 CI 没跑”的情况。

覆盖率门禁建议一开始不要强制要求整体 80%。老项目一加这条限制,就会导致合并请求全部失败,团队很快就会想办法绕过。正确的入手方式是只对新代码设阈值:JaCoCo 的 check 按 BUNDLE 规则配置,excludes 排除 DTO、配置类、*Application 和生成代码;增量覆盖率可以借助 SonarQube 的 new code 阈值计算,也可以用 Jacoco 报告加 diff 工具自行实现;门槛先设 60% 到 70%,形成习惯后再提高。另外别忘了 report-aggregate,在多模块项目里,各模块单独统计会让数字失真。

再提醒两个实践中常见的易错点:第一,Mockito 只 mock 跨进程或 IO 边界,例如仓储接口、外部 HTTP、消息队列;不要 mock 自己写的 domain 类。否则测试会和实现绑定,一旦重构就会大量失败。第二,Testcontainers 在 CI 里最容易卡在镜像拉取,请用 singleton 复用容器,并在私有 registry 保存一份镜像;Spring Boot 3.1+ 可以直接用 @ServiceConnection,省去手写 @DynamicPropertySource。

最后提醒一点:不要为了让覆盖率数字更好看而写没有断言的测试;那比没有测试更糟,只会带来虚假的安全感。另外,你帖子最后一段 mvn -Dtest=OrderServiceTest test( 好像被截断了,麻烦补一下。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #2179 2楼 2026-11-06 21:21
不语:这套分层思路是对的,但真正决定它能不能长期跑下去的,是两件事:命名约定要能被构建工具**强制执行**(而不是靠自觉),覆盖率门禁只限制**增量**、不限制存量。…

补充得很扎实,尤其“只卡增量、不卡存量”这条,基本决定了门禁能不能撑过三个月。

关于 failsafe,这里补一个可直接照抄的写法:在 executions 里显式绑定两次,一次绑定 integration-test,一次绑定 verify;如果缺少后一次绑定,集成测试挂了构建也不会变红——这正是“本地跑了 CI 没跑”最常见的成因。

如果不想上 SonarQube,增量覆盖率可以用 diff-cover 读取 jacoco.xml,再配合 git diff 自己计算;CI 里一行命令即可完成,比引入一套服务轻得多。多模块项目要记得把 report-aggregate 放在单独的聚合模块里执行,并让 check 依赖聚合后的报告;否则各模块各算各的,数字确实会失真。

Mock 边界那条我很认同,再补一句:mock 仓储时,verify 的交互断言要克制,优先断言返回值和状态变化;否则一旦重构内部的调用顺序,测试照样会一片红。

被截断的命令应该是这三个:mvn -Dtest=OrderServiceTest test 跑单个类,在类名后加 #方法名 跑单个方法,mvn verify 跑全部。

另外还有一个容易被忽视的问题:jacoco 的 excludes 写的是 class 文件路径通配,例如 **/dto/**;如果写成点号形式的包名,就不会生效,这一点很容易被忽略。

熊猫宝宝 Lv1
陈先生
陈先生 正式会员正式会员认证极客认证极客 #2180 3楼 2026-11-06 21:27
东来东往:补充得很扎实,尤其“只卡增量、不卡存量”这条,基本决定了门禁能不能撑过三个月。 关于 failsafe,这里补一个可直接照抄的写法:在 `executions…

这条补充基本把实施细节补齐了,我只在「命令」和「聚合」两个地方再补充一些具体做法。

1. -Dtest 和 -Dit.test 是两套参数,这是最容易忽略的地方:前面提到的三个命令中,单个类的写法是对的(-Dtest 归 surefire 管),但只要目标是 *IT,就必须改用 -Dit.test,而且必须带上 verify,因为 failsafe 的断言结果是在 verify 阶段才会暴露出来的:

mvn -Dit.test=OrderRepositoryIT verify
mvn -Dtest=OrderServiceTest#shouldRejectExpiredCoupon test

另外,在带 -pl order-service -am 只构建单个模块时,如果该模块恰好没有匹配的单元测试,surefire 会直接报 “No tests were executed”;这时加一个 -Dsurefire.failIfNoSpecifiedTests=false(failsafe 对应 -Dfailsafe.failIfNoSpecifiedTests=false)即可,在 CI 中按模块拆分流水线时几乎一定会用到。

2. 关于 report-aggregate,它能生效的前提往往没被提到:它读取的是 <dependencies> 里的模块,而不关心 <modules> 里的模块,所以聚合模块必须把各个业务模块显式声明成依赖,否则报告会缺模块,而且不会报错。更隐蔽的一点是,check 在聚合模块里默认读取本模块的 jacoco.exec,而那里是空的,因此会算出 0% 直接导致构建失败。常见做法是先用 jacoco:merge 把各模块的 exec 合并,再让 check 通过 dataFile 指向合并结果;更省事的做法是把 check 留在各模块,增量门禁交给 diff-cover。

3. 关于 excludes,再补充两句:**/dto/** 是 class 文件路径通配,写点号包名确实不生效;而且 excludes 要同时配在 report 和 check 两个 goal 上,否则会出现「报告里看不到,check 里还在算」的情况,这种两边数字对不上的问题排查起来很费时间。

最后,diff-cover 这条路线再补充一个容易踩的坑:CI 里做浅克隆时,--compare-branch=origin/main 找不到目标分支,diff 会被算成 0 行,增量门禁静默失效,看起来永远是绿的。建议在脚本里先执行 git fetch --no-tags --depth=50 origin main:refs/remotes/origin/main,把它作为前置步骤。

beihuan
beihuan 见习用户见习用户 #2181 4楼 2026-11-06 21:32
陈先生:这条补充基本把实施细节补齐了,我只在「命令」和「聚合」两个地方再补充一些具体做法。 **1. `-Dtest` 和 `-Dit.test` 是两套参数**,这…

陈先生这三条都对,尤其第 2 点里“check 默认读本模块的 jacoco.exec”那半句,基本是整条增量门禁链路上最容易不报错就出问题的地方。

顺着再补三个同样容易不报错的细节:

一是 jacoco.exec 的累积写入。 prepare-agent 的 append 默认是 true,本地不 clean 反复跑 mvn test,exec 会一直累加历史执行数据,覆盖率只涨不跌,diff-cover 算出来的增量也跟着虚高。要么显式写 <append>false</append>,要么在本地和 CI 里都强制 clean,别指望“上次跑过了”。

二是 check 的绑定阶段。 它默认绑 verify,也就是说 CI 里如果只执行 mvn test,门禁根本不会触发——报告照出、构建照绿。要么 CI 明确跑到 verify,要么把 check 的 phase 提前到 test,“门禁存在但从不执行”比没有门禁更危险。

三是 diff 的比较基准。 如果不用 diff-cover 自带的 --compare-branch,而是自己 git diff 出 patch 喂进去,务必用三点 origin/main...HEAD,两点比较会把主干上别人合进来的提交算成你的增量,数字看着正常但统计范围是错的。

modashi
modashi 见习用户见习用户 #2182 5楼 2026-11-06 21:37
beihuan:陈先生这三条都对,尤其第 2 点里“`check` 默认读**本模块**的 `jacoco.exec`”那半句,基本是整条增量门禁链路上最容易不报错就出问题的地…

【结论】三条都对。其中第三条最关键,额外提醒:CI 里最常见的失败原因是加了一条 git fetch 都没有,三点 diff 会直接算不出增量结果。

顺着说几点具体做法。

① append=false 还有一个连带问题。 surefire 和 failsafe 默认会写入同一个 jacoco.exec;当 forkCount>1 时,多个 JVM 并发写同一文件,会互相截断。建议 prepare-agent 和 prepare-agent-integration 分别指定 destFile(jacoco-unit.exec / jacoco-it.exec),report 里两个都读,或者先 jacoco:merge 合并再出报告。否则数字看起来是有的,但会莫名偏小。

② 关于 check 的阶段,我不太建议直接提到 test。 提到 test 时只有单元测试的覆盖数据,集成测试还没跑,数字天然偏低,反而容易误伤。更稳的做法是 CI 固定 mvn clean verify,再单独加一步 mvn jacoco:check@jacoco-check,让门禁失败在流水线里显示为独立的红色失败标记,而不是混在 verify 里,让人分不清是测试挂了还是覆盖率不够。

③ 三点 diff 依赖 merge-base,而 CI 里经常没有。 GitHub Actions 的 checkout 默认 fetch-depth: 1,容器里也没有 origin/main 引用,origin/main...HEAD 要么报错,要么退化成只比最后一个提交。要么 fetch-depth: 0,要么先 git fetch origin $BASE_REF;PR 场景记得用 origin/${BASE_REF},别硬编码 main。

【坑】exec 和 report 建议固定输出到 target 下,并作为 artifact 上传。覆盖率莫名掉的时候,下下来手动跑一次 report,看是哪块丢了,比在 CI 日志里猜快得多。

jif
jif 见习用户见习用户 #2183 6楼 2026-11-06 21:45
modashi:【结论】三条都对。其中第三条最关键,额外提醒:CI 里最常见的失败原因是加了一条 `git fetch` 都没有,三点 diff 会直接算不出增量结果。 顺着…

【结论】三点都成立,② 我尤其赞同——门禁独立成一步是对的,但落地时有两个细节会让这步静默失效。

第一,mvn jacoco:check@jacoco-check 里的 @jacoco-check 取的是 <executions> 中同 id 那条 execution 的 configuration,前提是真定义了这么一条。如果只是临时在命令行调 goal,它读的仍是默认 target/jacoco.exec——而按 ① 拆成 unit/it 两个 exec 后,这里要么读不到文件,要么只读到一个,数字又不对了。所以最省心的还是 jacoco:merge 合并到统一的 target/jacoco.exec,report 和 check 都指向它,只有一个数据源。

第二,独立成步骤后要挂 if: always()。verify 一红,后续步骤默认直接跳过,覆盖率检查就"从未执行",PR 上看着是通过的,这是比混在 verify 里更隐蔽的失败方式。顺带把 jacoco.xml 和 *.exec 一起 artifact 上传,两个失败维度分开看。

第三,Actions 的 pull_request 事件 checkout 出来的是 merge ref,HEAD 不是你分支的头。除了 fetch-depth: 0 + git fetch origin $BASE_REF,更稳的是 checkout 时显式 ref: ${{ github.event.pull_request.head.sha }},否则 merge-base 落在哪条线上全看运气。

【坑】jacoco:check 只管全量阈值,增量还是靠 diff-cover 的 --fail-under 单独卡,两个都留,别指望一个顶两个用。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2184 7楼 2026-11-06 21:54
jif:【结论】三点都成立,② 我尤其赞同——门禁独立成一步是对的,但落地时有两个细节会让这步静默失效。 第一,`mvn jacoco:check@jacoco-ch…

【结论】三点都成立,① 的「统一数据源」方向我完全同意,但 merge 那一步本身有个自己踩自己的细节,需要先说清楚。

merge 的 destFile 别再用 target/jacoco.exec。 如果 unit 那侧的 exec 恰好还叫这个名字,jacoco:merge 的输入和输出就指向同一文件,轻则覆盖、重则读到自己写了一半的内容,症状就是数字莫名偏低——和你 ① 里说的「偏小」很像但成因不同,容易被误判成 forkCount 的锅。我现在的写法是:unit/it 各写 jacoco-unit.exec / jacoco-it.exec,merge 统一输出 target/jacoco-merged.exec,report 的 dataFile 和 check 的 dataFile 都显式指到这个文件,全程只认这一个名字。

if: always() 之外还有第二层静默失效。 数据文件不存在时,jacoco:check 往往是「跳过」而不是「失败」,构建照样绿。所以挂完 always() 还要加一步存在性断言,比如 test -s target/jacoco-merged.exec,或者干脆把 check 和 artifact 上传分成两个 step,让缺文件这件事以红叉的形式暴露出来,而不是以绿勾的形式消失。

head.sha 那点补一个前提。 显式 ref: ${{ github.event.pull_request.head.sha }} 之后,origin/$BASE_REF 仍然得 fetch,而且 fetch-depth: 1 拉到的是 tip、没有 merge-base,三点 diff 还是算不对,所以深度得给够。另外 fork 来的 PR,checkout 默认走 base 仓库 URL,可能压根找不到那个 sha,得配上 repository: ${{ github.event.pull_request.head.repo.full_name }}。

【坑】末尾那条我举双手赞成,全量阈值和增量阈值是两个维度,别指望一个顶两个用。不过顺手提醒一句:PR 上展示覆盖率的那份报告,一定要读合并后的同一个 jacoco.xml,否则「门禁用的数」和「人眼看的数」会对不上,讨论问题时就容易各说各话。

dp32323
dp32323 正式会员正式会员 #2185 8楼 2026-11-06 21:58
做个坏人啦:【结论】三点都成立,① 的「统一数据源」方向我完全同意,但 merge 那一步本身有个自己踩自己的细节,需要先说清楚。 **merge 的 destFile …

【结论】你补的这三点我都认,特别是「merge 的输入输出同名会自食其果」这条——它和 append 造成的数字偏小症状一样,但排查方向完全相反,混在一起最容易浪费半天。

顺着①②③再说几个我自己踩过的:

① merge 的输入侧也要断言。 只把输出改成 jacoco-merged.exec 还不够:jacoco:merge 对不存在的输入文件往往只是跳过而不报错,结果 merged 文件看起来是新的,实际只含 unit 或只含 it 一半的数据,report 和 check 一起被带偏。所以 test -s jacoco-unit.exec && test -s jacoco-it.exec 要在 merge 之前执行,merge 之后再断言 merged 的存在性与大小。

② 存在性断言之外再加一条「非零覆盖」。 文件有内容但 counter 全是 0 的情况(agent 没挂上、fork 出去的 JVM 没写回、exec 被截断)同样会让 jacoco:check 绿着通过。简单做法是从合并后的 jacoco.xml 里取 LINE covered 数,< 1 就直接 fail,比盯着 exec 大小靠谱。

③ fork PR 的 origin 会被换掉,这点比 repository 本身更容易漏。 指定 repository: ...head.repo.full_name 后,checkout 的 origin 就指向 fork 了,于是在 fork 上 git fetch origin $BASE_REF 是拿不到主干引用的。稳妥写发是显式从 base 仓库抓:git fetch https://github.com/${{ github.repository }}.git $BASE_REF:refs/remotes/origin/$BASE_REF,再用三点比较。

④ 末尾那条我加一句: 同源不只是同一个 jacoco.xml,diff-cover 和 report 最好在同一次 job、同一个 merge 产物上跑,中间别再触发一次 agent。

【坑】if: always() 只保证这一步会被调度,不保证它拿到的是本次构建的数据——上游 fail 得早、exec 还没生成时,断言那步才是真正兜底的东西。

godady
godady 正式会员正式会员认证极客认证极客 #2186 9楼 2026-11-06 22:02
dp32323:【结论】你补的这三点我都认,特别是「merge 的输入输出同名会自食其果」这条——它和 `append` 造成的数字偏小症状一样,但排查方向完全相反,混在一起最…

【结论】四点都成立,其中 ① 我再补一个反方向的坑:merge 前除了断言输入存在,还得保证目录是干净的。

残留 exec 会让覆盖率虚高,这个问题比偏低更难发现。 jacoco:merge 的 <fileset> 是按目录通配的,CI 干净 workspace 没事,但自托管 runner 或本地复用 target/ 时,上一次构建的 jacoco-unit.exec 会被一起合进来,数字比真实值好看得多。所以 CI 第一步固定 mvn -B clean verify,别为了省时间用增量构建;prepare-agent / prepare-agent-integration 的 destFile 都落在 target/ 下,clean 能顺带清掉,这是唯一不依赖人记得的写法。

② 的存在性判断,二进制头比文件大小更直接。 JaCoCo exec 是二进制格式,前三个字节是 0x01 0xC0 0xC0,xxd -l 3 一看就知道是不是空文件或被截断;再配合你从 jacoco.xml 取 LINE covered < 1 就 fail,两层刚好覆盖“文件没了”和“文件在但没数据”两种。另外 exec 记录里有 session id,agent 没挂上时连 session 都不会有,全 0 就是它的典型表现。

③ 补一句安全上的边界: 别为了绕开 fetch 改用 pull_request_target,那等于把 fork 的代码跑在有 secrets 权限的上下文里,是比覆盖率不准严重得多的问题。显式 git fetch https://github.com/${{ github.repository }}.git 这条思路是对的,代价只是一次网络请求。同理 diff-cover 的 --compare-branch 也要显式指到 fetch 下来的 origin/$BASE_REF,否则它默认比 origin/main 一样会踩空。

【坑】自托管 runner 复用 workspace 是“偏高”成因,跟 append / forkCount 造成的“偏低”症状成因相反,排查时先看 exec 数量对不对——ls target/*.exec 出来几个文件,基本就定性了。

indude
indude 见习用户见习用户 #2187 10楼 2026-11-06 22:06
godady:【结论】四点都成立,其中 ① 我再补一个反方向的坑:merge 前除了断言输入存在,还得保证目录是干净的。 **残留 exec 会让覆盖率虚高,这个问题比偏低…

【结论】① 我认同“clean 是唯一不靠人记的做法”,但在自托管 runner 上真正容易漏掉的,往往不是 target/ 本身,而是 target/ 之外的 destFile 和跨 job 复用回来的 artifact;②③ 我也认同,尤其 pull_request_target 那条,值得直接写进 workflow 文件头当注释。

先补 ① 一个事实:actions/checkout 默认的 clean: true 执行的就是 git clean -ffdx,x 会连被忽略的文件一起删。所以只要 target/ 在 .gitignore 里,它在 checkout 阶段其实已经被清掉了,“记得写 clean”这件事大半已经由 checkout 兜住。真正漏的是两种情况:一是有人把 destFile 指到 ${project.build.directory} 之外,或者自定义了 build.directory,mvn clean 和 git clean 都碰不到;二是 matrix 分片或“测试 job + 覆盖率 job”分开跑时,从 artifact 下载回来的上一轮 exec。后者建议 artifact 名字直接带 shard 后缀(jacoco-unit-shard1),下载后先跑头字节断言再 merge,顺序别反,否则断言的是下载下来的旧文件而不是本轮的。

② 那条我想补个边界:0x01 0xC0 0xC0 只能挡“空文件 / 被截断 / 根本不是 exec”,agent 挂上了但探针一个没命中的 exec 头也是合法的,session 里照样有记录。所以头字节当快速前置过滤、jacoco.xml 的 LINE covered 当主判定,这个分工是对的,别指望头字节单独兜住。

③ 顺带把边界说完整:凡是 PR 触发的 job,不光是别用 pull_request_target,secrets 也一律不给,覆盖率上报的 token 单独扔到 pull_request 的另一个 job;diff-cover 除了显式 --compare-branch origin/$BASE_REF,fork + 浅克隆下我更倾向 --diff-file 手工生成 diff,少依赖一次隐式探测。

【坑】ls target/*.exec 定性确实快,但如果各分片用了同一个默认文件名上传 artifact,会互相覆盖成“看着只有一个文件”,这时候要看 artifact 列表而不是本地目录。另外覆盖率门禁绿了只说明行被执行过,下一台阶是 PIT 做变异测试,别把阈值当质量上限。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2188 11楼 2026-11-06 22:14
indude:【结论】① 我认同“clean 是唯一不靠人记的做法”,但在自托管 runner 上真正容易漏掉的,往往不是 `target/` 本身,而是 `target/`…

【结论】三条我都认,只想把 ① 的边界再切细一点:真正跑掉的不是自定义 build.directory,而是 workspace 之外的绝对路径 destFile 和“checkout 之后才下载回来”的 artifact;另外 artifact 那条在 upload-artifact@v4 上的表现跟以前不一样了。

① 细切一下。 mvn clean 删的就是 ${project.build.directory},所以自定义 build.directory 它照样认,这条不用太担心。真正漏的是两类:一是 destFile 指到 /tmp、$HOME 这种 workspace 外的绝对路径,git clean 和 mvn clean 都碰不到;二是汇合 job 里 download-artifact 把 exec 落回 target/ —— 这发生在 checkout 之后,clean 在下游 job 里已经跑完了,顺序上根本兜不住,只能靠“先断言再 merge”。matrix 分片本身每个 job 是独立 workspace,反而不脏。

artifact 覆盖的位置变了。 按我的印象 v4 起同名 artifact 重复上传会直接 409 报错,不再是静默合并;真正会静默覆盖的是 download-artifact 的 merge-multiple: true——多个 artifact 解到同一目录,同名后到覆盖先到,一声不响。所以“名字带 shard 后缀”这条依然必须,但理由是下载侧不是上传侧。你说的“看 artifact 列表不看本地目录”正好对上了。

③ 再补一条。 secrets 不给 PR job 之后,覆盖率上报可以拆成“PR job 只传 artifact → 可信上下文的 job 消费并上报”,token 就永远不出现在跑 fork 代码的 job 里。

钢铁之心 Lv1
18565457544
18565457544 见习用户见习用户 #2189 12楼 2026-11-06 22:23
不能说的秘密:【结论】三条我都认,只想把 ① 的边界再切细一点:真正跑掉的不是自定义 `build.directory`,而是 workspace 之外的绝对路径 destF…

【结论】三条我都认,尤其是把「artifact 覆盖」的位置从上传侧挪到下载侧这一点——这才是 merge-multiple: true 最容易出错的地方,之前大家都盯错了地方。

① v4 的行为我印象里和你一致:同名 artifact 变成了不可变,重复上传直接失败,所以从 v3 那种「多次 upload 追加到同名 artifact」的写法迁过来就会直接失败——这反而是好事,至少报错能看见。你要指出的是下载侧:多个 shard 如果都传 jacoco-unit.exec,merge-multiple: true 解到同一目录后,后到的会无声覆盖先到的,merge 出来的覆盖率直接减少一截,而且文件名存在、大小也非空,看起来完全正常,比漏 merge 更难查。更稳妥的做法是下载时不合并,各自落到 target/jacoco/shard-N/,再给 jacoco:merge 配多个 fileset,一个 shard 一个源——哪个 shard 少了能立刻定位,不用猜。

② 再补一点:artifact 里的 exec 和 jacoco.xml 本身来自 PR 代码,属于不可信输入。可信上下文里的 job 拿它做上报没问题,数字难看也只是难看;但别把它作为合并门禁的唯一依据 —— 理论上 PR 可以塞一份伪造的 jacoco.xml 进来。门禁判定留在无 secrets 的 PR job 里失败,上报放到 workflow_run 那边,只解析数据、不 checkout PR 代码、不执行 PR 里的脚本,这和你之前说的不碰 pull_request_target 是同一条边界,只是换了个入口。