1. 覆盖率数字两边对不上问题往往出在 agent 而不是插件前阵子帮一个团队排查问题,场景很典型:本地在 IDEA 里点Run with Coverage跑完单测,类覆盖率显示 68%,提交上去之后流水线上报出来的却是 41%,评审会上被追问了半天。我第一反应不是代码有问题,而是这两套数据根本不是同一个采集器产出的。IDEA 的覆盖率按钮走的是它自带的采集逻辑,Maven 构建里的覆盖率走的是 jacoco-maven-plugin 注入的 agent,两者的统计范围、类过滤规则、甚至是 class 文件的读取时机都不一样。所以指望两边数字吻合,本身就是个错误预期。这篇内容想解决的就是这件事:如何在 IDEA 这个开发环境里,把 Maven 和 JaCoCo 完整地配起来,让它产出的报告既能自己看,也能直接对接后续的质量卡口。适合已经写过单元测试、但对覆盖率工具链还停留在点一下按钮阶段的同学,也适合需要给团队定一套统一口径的负责人。maven负责构建生命周期,jacoco负责字节码层面的探针注入与数据落盘,IDEA 只是一个承载它们的容器——把这三者的边界搞清楚,配置就不会乱。1.1 JaCoCo 的统计口径:行覆盖、分支覆盖与指令覆盖很多人第一次看 JaCoCo 报告会懵,为什么同一次执行,行覆盖率 70%,分支覆盖率只有 45%,指令覆盖率又是 63%。这不是算错了,而是三个不同的度量维度。JaCoCo 在类加载时通过字节码插桩,在方法的每个跳转边和每条指令序列上打探针,执行到就打勾,最后按不同粒度汇总。指令覆盖(Instruction)最细粒度,统计的是 JVM 字节码指令被执行的比例,基本等同于代码执行了多少。分支覆盖(Branch)专门盯 if/else、switch、三元表达式这类有多个出口的结构,一个 if 有两个分支,只跑了 true 那一支,分支覆盖率就是 50%。行覆盖(Line)把指令映射回源码行号,一行里只要有一条指令执行了就算覆盖。所以行覆盖率天然比分支覆盖率好看。方法覆盖与方法复杂度方法级聚合,复杂度则是按圈复杂度算的,和覆盖率不是一回事,别混着看。这里有个实操提醒:行覆盖率高不代表逻辑测得全。一段 20 行的 if-else 链,只跑通一条路径,行覆盖率可能也有 60% 以上,但分支覆盖率会低得刺眼。我自己定卡口时优先看分支覆盖,行覆盖只作为兜底参考。另外,JaCoCo 统计的是运行期真正加载的类,如果一个类压根没被任何测试触达,它在报告里默认是未加载状态,不会算进分母——这一点很容易让人误以为覆盖率虚高,后面讲report目标时会说怎么把这类类也拉进来。1.2 IDEA 自带的覆盖率按钮和 Maven 插件是两套东西IDEA 工具栏那个Run with Coverage用起来太顺手了,顺手到很多人以为它就是 JaCoCo。严格说,新版 IDEA 在大多数配置下确实会用 JaCoCo 作为底层采集器,但它注入的 agent 参数、class 过滤规则、以及读取的 class 输出目录,都是 IDEA 自己决定的,和你在 pom 里写的excludes、includes完全无关。我遇到过最典型的差异是这两条:对比项IDEA 覆盖率运行Maven JaCoCo 插件agent 参数来源IDEA 内部生成,不读 pom由prepare-agent写入,可精细控制类过滤规则IDEA 编译输出范围内全量受includes/excludes控制数据落盘位置IDEA 运行时临时目录${project.build.directory}/jacoco.exec报告形式IDE 内嵌视图HTML/XML/CSV,可归档、可对接平台是否可复现依赖本地 IDE 版本与设置只要有 pom 就能复现看过这张表就知道,为什么本地好看线上难看。IDEA 的覆盖率窗口是给人看的,JaCoCo 的 exec 和 HTML 报告是给流程用的,两者都要,但不能混为一谈。我个人的习惯是:开发阶段随手点覆盖率按钮快速验证,但凡是需要对外汇报、需要接质量卡口的数字,一律以mvn verify产出的报告为准。1.3 什么场景下必须走 Maven 插件这条路不是所有项目都需要配插件。如果只是单模块小项目、自己看看覆盖情况,IDEA 那个按钮足够了,配插件纯属给自己找事。但只要命中下面任意一条,就得老老实实写 pom 配置:项目是多模块结构,需要一份跨模块聚合的整体报告;需要把覆盖率卡在构建里,低于阈值直接让构建失败;需要把报告交给外部平台解析,通常要求 XML 格式而不是 IDE 内嵌视图;需要排除某些自动生成的代码(模板生成类、DTO、配置类),让数字反映真实手工代码的质量;需要在不同机器、不同人、不同时间点跑出一致可比的结果。这几条里,多模块聚合和排除规则是最容易踩坑的,后面会单独拆开讲。先说结论:JaCoCo 的配置成本主要不在写对,而在写对之后数字可信。2. 落配置之前,先把工程底子摸清楚我见过太多人直接搜一段 pom 片段贴进去,然后跑不通就到处问。JaCoCo 的配置本身只有几十行,但它对工程结构的假设很硬:插件挂在哪个 pom、exec 文件生成在哪、report 从哪读数据,全都是强耦合的。所以在敲第一行 XML 之前,先花五分钟确认三件事:工程是单模块还是多模块、JDK 是什么版本、现在的 surefire 是怎么配的。这三件事决定了你后面要写多少行配置。顺带说一句,别在 IDEA 里配完就完事。真正能验证配置对不对的,是切到命令行执行一次mvn clean verify,看产物是否如期出现。IDEA 的 Maven 面板本质是调用同一个 Maven,但它会把很多输出折叠起来,报错信息不全,排查时很痛苦。2.1 单模块与多模块:插件到底该挂在哪一层单模块最简单,直接在唯一的 pom 的buildplugins里声明jacoco-maven-plugin,配prepare-agent和report两个执行即可,exec 文件默认落在target/jacoco.exec,报告落在target/site/jacoco/。多模块就有讲究了。常见做法是把插件声明放进父 pom 的pluginManagement,然后在父 pom 的buildplugins里真正引入,这样子模块会自动继承,每个模块各自生成自己的 exec 和报告。但这个方案的缺点是:报告是分散的,你想看一个整体数字,得挨个打开子模块的 HTML。所以多模块实际有两套路线,选哪套取决于你到底要看什么:路线做法产出适用场景分散报告插件继承到各子模块每个子模块一份 exec HTML各模块独立负责自己的覆盖率聚合报告单独建一个聚合模块,用report-aggregate一份覆盖全部模块的 HTML/XML需要看项目整体数字、对接平台选分散路线,配置一次性写在父 pom 就完事;选聚合路线,除了父 pom,还要额外建一个只做聚合的模块,并且这个模块必须用dependency把所有被聚合的模块都声明一遍——注意是 dependency 不是 dependencyManagement,这是聚合报告最常见的失败原因,后面第 5 章会详细讲这个坑。2.2 版本对齐:JaCoCo、surefire、JDK 三者的兼容矩阵JaCoCo 是通过解析 class 文件的字节码版本来工作的,JDK 版本一升,class 文件版本号就变,旧版 JaCoCo 直接抛Unsupported class file major version。这个错误信息很直白,但很多人不知道往上翻到哪一版才够。JaCoCo 版本支持的最高 class 版本大致对应的 JDK0.8.6 及以下59JDK 15 及以下0.8.761JDK 170.8.861JDK 170.8.1165JDK 210.8.1267JDK 23我的建议是直接用当前较新的稳定版,除非你的项目还卡在老 JDK 上。版本写在properties里,不要散落在各处,升级时改一处就行。另外 surefire 也有讲究:老版本 surefire(2.x)在 fork 多个 JVM 时,exec 文件的写入行为和新版(3.x)不同,3.x 对并行执行的合并支持更好。如果你的项目还在 surefire 2.x 并且开了forkCount1,建议顺手升到 3.x,能省掉一堆覆盖率丢数据的怪问题。2.3 本地环境里容易被忽略的三个前置条件配置之前有三件事值得确认,都是看起来无关紧要、出问题时却能卡你半天的那种。第一,JAVA_HOME和 IDEA 里配的 JDK 是不是同一个。Maven 命令行走的是环境变量,IDEA 的 Maven 面板走的是 IDEA 设置里的 JDK,两者不一致时,命令行能跑通、面板报错,或者反过来。这种不一致的表现往往是某个类找不到,极难往 JDK 版本上联想。第二,本地仓库里是否已经有对应版本的插件。有些环境网络受限,插件下载不下来,prepare-agent阶段直接失败。提前看一眼本地仓库有没有org/jacoco/jacoco-maven-plugin/对应版本这个目录,能省一次排查。第三,是否开启了maven.test.skip。这个属性一开,测试根本不会编译,自然不会有覆盖率数据,但构建依然成功,report目标会生成一个空报告而不报错。我第一次遇到时对着空报告看了十几分钟,以为插件没生效。提示:配置改动之后的第一件事,永远是mvn clean verify,不是mvn test。report目标默认绑定在verify阶段,只跑test不会生成报告——这是新手最常见的疑问来源。3. 把 jacoco-maven-plugin 写进 pom:三段配置拆开讲现在开始动真格。JaCoCo 插件的目标有好几个,但日常只需要关心四个:prepare-agent、report、check、report-aggregate。前两个是必配的,第三个是可选的卡口,第四个只在多模块聚合时用。下面按顺序拆,每一段都说明为什么这么写,而不是只给你一段能跑的 XML 就完事。写之前先说一个原则:所有版本号和路径都抽到 properties 里。这样后面升级、改排除规则、接外部平台时,改动集中在头部,不会漏改。3.1 properties 与 pluginManagement 的基础骨架先看父 pom 里该有的部分。这段配置我基本是原样复用的,重点看注释里的意图:properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding !-- 插件版本单独抽出来,升级只改这一行 -- jacoco.version0.8.12/jacoco.version surefire.version3.2.5/surefire.version !-- 报告输出目录,统一约定,方便后续脚本读取 -- jacoco.report.path${project.build.directory}/site/jacoco/jacoco.report.path jacoco.exec.path${project.build.directory}/jacoco.exec/jacoco.exec.path /properties build pluginManagement plugins plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version${jacoco.version}/version /plugin /plugins /pluginManagement plugins plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId /plugin /plugins /build这里用pluginManagement声明版本、plugins里引用不带版本的方式,是为了让子模块能自动继承又不重复写版本号。如果你的项目是单模块,直接合并成一段写在plugins里带上版本即可。注意pluginManagement只是声明,不引入,很多人只写了pluginManagement,发现插件根本没执行,就是漏了下面那段plugins。${project.build.directory}是 Maven 内置变量,指向当前的target目录,用它拼路径的好处是mvn clean能把产物一起清掉,不会留下过期的 exec 文件干扰下一次统计。3.2 prepare-agent:exec 文件从哪来,为什么要在测试开始前跑prepare-agent干的事只有一件:在 JVM 启动前,把 JaCoCo 的 agent jar 路径和一堆参数塞进一个属性里,后续 surefire fork 出来的测试 JVM 会带上-javaagent参数加载它。测试跑完,JVM 退出时 agent 把采集到的数据写到 destFile 指定的位置。plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId executions execution idprepare-agent/id goals goalprepare-agent/goal /goals configuration !-- exec 落盘位置 -- destFile${jacoco.exec.path}/destFile !-- true 表示追加,多次执行数据会累加 -- appendtrue/append !-- 只给测试阶段注入,skip 掉不应该被打桩的类 -- exclClassloadersun.reflect.DelegatingClassLoader/exclClassloader /configuration /execution /executions /pluginprepare-agent默认绑定在initialize阶段,这个顺序很关键——必须早于test(surefire 启动测试 JVM 的阶段)。如果你手动改过phase,写成test或更晚,agent 就不会生效,覆盖率永远是 0。我见过有人为了让某个前置步骤先跑,把 phase 改成process-test-classes,结果就是数据全空。关于append:默认是true,意味着同一个 exec 文件会被反复追加。这在单次clean verify里没问题,但你在 IDEA 里连续点几次运行、或者没 clean 就直接再跑一遍,数据就会叠加。叠加本身不会让覆盖率下降,但会让已删除或改动过的类带着旧数据残留在报告里,造成误判。我的习惯是本地开发时设成false,保证每次都是干净的一轮;CI 上多模块并行时才改回true。还有一个细节:prepare-agent会默认把 agent 参数写进argLine属性。如果你在 surefire 里也手动写了argLine,就会互相覆盖,这是第 5 章要展开的重点坑。3.3 report:报告怎么落到 target/site/jacocoreport目标负责读取 exec 文件,和 class 文件做比对,生成 HTML、XML、CSV 三种格式的报告。它默认绑定在verify阶段,这解释了为什么mvn test不出报告。execution idreport/id phaseverify/phase goals goalreport/goal /goals configuration dataFile${jacoco.exec.path}/dataFile outputDirectory${jacoco.report.path}/outputDirectory formats formatHTML/format formatXML/format /formats /configuration /executionformats这一项值得单独说。HTML 是给人看的,带源码高亮,能点进具体某一行的覆盖情况;XML 是给平台解析的,大部分质量平台读的都是这个。两个都生成,代价只是多几秒,换来的是后面不用回头改配置。CSV 我基本不用,除非要做批量统计脚本。报告目录里最重要的是index.html,它按包、按类列出覆盖率,点进去能看到源码级的红黄绿标记。要提醒的是,报告里未加载的类默认不会出现在列表中,如果你发现某个类明明没被测到却找不到,可以在report里加上includeSourceFiles相关配置,或者更简单的做法是用check目标,它会把未覆盖的类也算进来。3.4 多模块聚合:report-aggregate 与手动合并 exec 两条路线多模块想出一份整体报告,有两条路。第一条是用report-aggregate目标,它会在一个专门的聚合模块里,把所有依赖模块的 exec 文件读进来合并输出。第二条是先把各模块的 exec 文件拷到同一目录,再用merge目标合并,最后跑一次普通report。前者官方,后者灵活。先看官方路线。建一个独立模块(比如叫coverage-report),它的 pom 必须是这样的结构:dependencies !-- 关键:要把所有需要聚合的模块作为 dependency 声明进来 -- dependency groupIdyour.group/groupId artifactIdmodule-a/artifactId version${project.version}/version /dependency dependency groupIdyour.group/groupId artifactIdmodule-b/artifactId version${project.version}/version /dependency /dependencies build plugins plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId executions execution idaggregate/id phaseverify/phase goals goalreport-aggregate/goal /goals /execution /executions /plugin /plugins /build这段配置里最容易错的就是 dependencies。report-aggregate是顺着依赖关系去找 exec 文件的,没有声明依赖的模块,数据不会被聚合进来。而且声明的必须是真的dependencies而不是dependencyManagement,前者会参与构建图的解析,后者只是版本声明。我见过一个团队配了三小时聚合报告一直是空的,最后发现是写成了dependencyManagement。第二条路线适合模块多、且各模块构建时间错开的场景:在每个模块构建完成后,把target/jacoco.exec统一拷贝到一个固定目录,最后一个步骤用merge目标合并成单个 exec,再基于它生成报告。这条路线的好处是你可以选择聚合哪些模块,比如只聚合变更过的模块,而不是每次都全量。缺点是需要写额外的脚本,构建配置的复杂度上去了。3.5 excludes:把不该算的代码从分母里剔出去这一节的内容,直接决定了你的覆盖率数字有没有参考价值。一个中大型项目,自动生成的代码、DTO、配置类、启动入口类往往占相当比例,这些代码本来就不该写单测,留在分母里只会把整体数字拖下来,进而让团队对覆盖率指标产生反正也上不去的放弃心理。excludes配在prepare-agent里,匹配的是 class 文件的全限定名,支持*和?通配:configuration destFile${jacoco.exec.path}/destFile appendfalse/append excludes !-- 生成的代码:模板引擎、协议生成器产出的类 -- exclude**/generated/**/exclude !-- 纯数据载体 -- exclude**/dto/**/exclude exclude**/vo/**/exclude !-- 配置类,绝大多数不含业务逻辑 -- exclude**/*Config.class/exclude exclude**/*Configuration.class/exclude !-- 启动入口 -- exclude**/*Application.class/exclude /excludes /configuration有几条经验值得强调。不要用包名一刀切,比如把整个**/service/**排除掉,那覆盖率就成了自欺欺人。排除的对象应该是结构上就不需要测试的代码,判断标准是:如果你给它写单测,测的其实是框架的行为而不是你的逻辑,那就可以排。另外,excludes只影响 prepare-agent 阶段的打桩,不影响report阶段的分母计算。也就是说,被排除的类不会出现在报告里,但如果你在check里也想要同样的排除规则,需要在report和check里各配一遍——这两个目标各有自己的excludes配置项,不会自动继承 prepare-agent 的。这个细节文档里写得不显眼,但坑了不少人。对于用 Lombok 的项目,还有一个专门的开关:在项目根目录建lombok.config,加上lombok.addLombokGeneratedAnnotation true。这样 Lombok 生成的 getter/setter/equals 等方法会带上lombok.Generated注解,JaCoCo 从 0.8.0 开始会自动过滤掉这些方法。不加这一行,你会看到一堆 getter 是红色的,白白拉低分支覆盖率。4. IDEA 里的操作路径:Maven 面板、运行配置与报告查看pom 写完,接下来是在 IDEA 里把它跑起来。这一步看着简单,但真正高效的用法和每次切命令行之间差别很大。我用下来最顺的组合是:Maven 面板负责触发,Maven 运行配置负责保存常用的命令组合,HTML 报告负责看细节,IDEA 内嵌视图只用于快速抽查。4.1 reload 之后 Maven 面板里多出了什么改完 pom 之后,IDEA 右上角会出现一个Maven 变更需同步的小图标,点它,或者右键项目选Reload Maven Project。同步完成后,展开右侧 Maven 面板,你会看到对应模块下多了jacoco:prepare-agent、jacoco:report这些目标。如果没出现,说明 pom 没被正确解析,先看 IDEA 底部的 Build 输出有没有语法错误提示,再看plugins是不是写成了pluginManagement。同步之后有个小技巧:Maven 面板里双击Lifecycle - verify就能直接跑完整构建,等价于命令行mvn verify。但注意,面板默认不会带clean,连续跑两次会命中 exec 累加的问题。所以我更推荐用下面说的运行配置,把clean verify打包成一条命令。另外,如果你发现 Maven 面板里显示的 JDK 版本和预期不符,可以在Settings - Build Tools - Maven - Runner里检查 JRE 配置,这一项不一致会导致编译和插桩用不同的 class 版本,表现是报告里的类名对不上。4.2 用 Maven 运行配置替代反复切命令行在 IDEA 右上角的运行配置下拉里选Edit Configurations,点加号,选 Maven,新建一条。关键字段这样填:字段建议值说明Working directory项目根目录多模块时填父 pom 所在目录Command lineclean verify -DskipITs带 clean,避免 exec 累加Profiles按需填,通常留空有 test profile 时再指定JRE与项目 JDK 一致不一致会导致 class 版本冲突这样配置的好处是可以一键复用,而且失败时 IDEA 会把完整的 Maven 日志留在 Run 窗口里,比命令行翻屏舒服得多。我给这条路线的常用变体有三条:全量跑(clean verify)、只跑单模块(clean verify -pl module-a -am)、跳过卡口只想看数字(clean verify -Djacoco.skipfalse -DskipCheck),后者需要你在 pom 里给 check 留个开关属性。顺带提一个实操经验:跑之前先确认没有残留的target目录。虽然clean会清,但如果构建中途被打断,可能留下不完整的 class 文件和 exec,下一次统计就会出现类找不到或者数据错乱。我在排查阶段经常手动删一次target再跑,能排除掉一大类假故障。4.3 报告页面怎么读:红黄绿三色背后的含义verify跑完后,报告在target/site/jacoco/index.html,IDEA 里直接右键这个文件选在浏览器打开即可,或者用 IDEA 内置的 HTML 预览。首页是一张按包聚合的表,列有指令覆盖、分支覆盖、行覆盖、方法覆盖、类覆盖,每一列都有未覆盖数/总数和百分比。点进某个包,再点进某个类,会看到源码级的着色。这里的颜色规则值得记清楚:绿色背景该行所有指令都已执行,所有分支都走到了。黄色背景行被执行了,但分支没走全(典型的就是只测了 if 的 true 分支)。红色背景该行完全没被执行。红色菱形在行号边上,通常表示一个未覆盖的分支出口。很多人只盯百分比,其实黄色行才是最有价值的信号。绿色行说明测过了,红色行说明没测,唯独黄色行告诉你测了,但测得不全,这类地方往往藏着边界条件的 bug。我做覆盖率梳理时,一般先按黄色行数排序,优先补这些地方的测试用例,收益比闷头冲百分比高得多。4.4 IDEA 覆盖率窗口与 HTML 报告的分工前面说过这两套数据口径不同,但在实际工作流里,它们各有不可替代的位置。IDEA 的覆盖率窗口优势是能直接跳到源码、能按方法折叠、能对比两次运行的差异,适合边写测试边看;HTML 报告的优势是可归档、可分享、能反映完整的排除规则,适合阶段性验收和对外汇报。我的用法是:写单测的过程中点 IDEA 的Run with Coverage,盯着当前正在写的类,看有没有漏掉的分支;写完之后跑一次clean verify,用 HTML 报告确认整体数字和排除规则是否生效。两边数字不一致时不慌,先对比一下 IDEA 的设置里有没有额外排除某些包,通常差异就出在这。还有一个细节值得提:IDEA 的覆盖率窗口里有一个Show coverage data of下拉,可以切换不同的运行结果。如果你连续跑了多次,这里会积累多条记录,记得选最新那条,否则看到的是历史数据。这个小细节让我浪费过不少时间。5. 五个真实踩过的坑,以及逐步排查的完整链路这一章是全文最想写的部分。前面那些配置,照着文档抄基本能跑通;真正耗时间的是跑不通的时候怎么定位。下面五个问题都是我或者身边人真实遇到过的,每一个我都把排查链路完整写出来,你可以照着顺序复现,而不是直接跳到答案。5.1 exec 文件生成了,但覆盖率报出来全是 0现象:clean verify成功,target/jacoco.exec也确实存在,但打开 HTML 报告,所有类都是红色的,覆盖率为 0。排查链路:先看 exec 文件大小。正常跑过测试的项目,这个文件至少几 KB 到几百 KB。如果只有 0 字节或者几十字节,说明 agent 根本没采到数据。检查 surefire 是不是真的跑了测试。看构建日志里Tests run: X, Failures: 0这一行,X 是不是 0。如果是 0,问题在测试本身没被识别(命名不符合*Test、*Tests、*TestCase规则),跟 JaCoCo 无关。检查prepare-agent的 phase。有没有被手动改成test或更晚?标准应该是initialize。检查 surefire 的 argLine 是不是被自己的配置覆盖了。这是最常见的原因,详见下一节。检查测试是不是跑在 fork 出来的独立 JVM 里,且forkCount或reuseForks的配置导致 agent 参数没传下去。按这个顺序走,90% 的情况在第三步或第四步就能定位。我自己遇到过最隐蔽的一次,是项目里有个自定义的 surefire 配置把argLine写死成了空字符串,导致 agent 参数被完全吃掉,日志里一点报错都没有,只是数据为 0。5.2 argLine 被自己的 JVM 参数覆盖这是 JaCoCo 集成里最经典的坑,值得单独展开。prepare-agent默认会把 agent 的-javaagent参数写进名为argLine的 Maven 属性。如果你在 surefire 里也写了:configuration argLine-Xmx1024m -Dfile.encodingUTF-8/argLine /configuration那么你写的这行会完全覆盖JaCoCo 注入的内容,agent 参数消失,覆盖率归零。解决办法有两条,推荐第一条:!-- 方案一:用 {argLine} 延迟解析,把自己需要的参数追加在后面 -- configuration argLine{argLine} -Xmx1024m -Dfile.encodingUTF-8/argLine /configuration!-- 方案二:给 agent 参数换个属性名,再用变量引用 -- !-- 在 prepare-agent 里加:propertyNamejacoco.agent.argLine/propertyName -- configuration argLine${jacoco.agent.argLine} -Xmx1024m/argLine /configuration方案一里的{}是 surefire 特有的延迟求值语法,它在测试 JVM 启动时才把argLine属性替换进来,所以能同时容纳 JaCoCo 注入的内容和你自己的参数。方案二更显式,不会被其他插件的属性污染,但需要你在两处配合修改。判断自己有没有踩这个坑,最快的办法是打开构建日志搜javaagent,正常情况下你能看到一长串-javaagent:/path/to/org.jacoco.agent-runtime.jardestfile...。搜不到,就是这个原因。5.3 聚合模块跑出来是空报告现象:多模块项目配了report-aggregate,报告目录生成了,但里面只有一行总计,全是 0,或者干脆只有 index 页没有类列表。排查链路:确认聚合模块的 pom 里是不是用了dependencyManagement声明被聚合模块。必须是dependencies,这是第一嫌疑。确认被聚合模块在本次构建中确实执行过测试。如果用了-pl只构建聚合模块而没带-am,上游模块没跑,自然没数据。确认上游模块的 exec 文件路径和聚合模块读取的路径一致。默认都是target/jacoco.exec,但如果某处改过destFile,这里就对不上。确认聚合模块的report-aggregate绑定在verify之后,且它在 reactor 构建顺序里排在所有上游模块之后。依赖关系写对了 Maven 会自动排序,写错了就不行。确认没有maven.test.skip之类的跳过开关在生效。我踩过最郁闷的一次是第二条:为了省时间用-pl coverage-report只跑聚合模块,结果上游全跳过了,报告当然是空的。后来固定用-pl coverage-report -am,-am会把依赖模块一起构建,问题就没了。5.4 Lombok 与自动生成的代码拉低整体数字现象:整体覆盖率比预期低了一截,点进报告发现大量 getter/setter、equals、hashCode、toString方法标红,而这些明明是被覆盖到的——只是没被单独测试。根因:Lombok 是在编译期生成字节码的,JaCoCo 看到的是生成后的 class,它不知道这些方法来自注解。于是这些方法被当成了应该被测到但没测到的普通方法,计入分母。解决:三步。项目根目录建lombok.config,写入lombok.addLombokGeneratedAnnotation true。这一行让 Lombok 给生成的方法加上lombok.Generated注解。确认 JaCoCo 版本不低于 0.8.0,它会自动识别这个注解并过滤。重新clean verify,观察那些 getter 方法是否从报告里消失。注意lombok.config的位置很关键,它默认只对所在目录及子目录生效,所以必须放在项目根目录或者各模块的根目录。放在某个深层包里,上层模块就不生效,表现是部分类的 getter 消失了,部分还在。类似的还有各种代码生成器产出的类,这类没法靠注解解决,只能用exclude按路径排除,规则写在第 3.5 节。5.5 重复执行导致 exec 累加,数字越跑越高现象:同一天里跑了好几次,第一次覆盖率 55%,第二次 62%,第三次 70%,看着像进步,其实代码一行没改。根因:append默认为true,同一个 exec 文件被反复追加。更麻烦的是,改过或者删掉的类,旧数据还留在 exec 里,report 阶段如果 class 文件还在(哪怕内容变了),就会把旧数据和新数据混在一起统计,得出一个谁也对不上的数字。解决:本地开发时把append设成false,并且每次都带clean跑。如果因为多模块并行不得不保留appendtrue,那就必须在整个构建开始前手动删一次所有 exec,或者用 Maven 的clean生命周期保证target被整体清掉。判断当前是不是脏数据,有个简单办法:看报告里的类数量是不是比你实际写的类多。多了,基本就是历史残留。我现在的习惯是,凡是看到覆盖率数字出现不太可能的涨幅,第一反应就是先clean再跑一次。6. 用 check 目标把覆盖率变成构建卡口报告能看之后,下一步才是让它产生约束力。只出报告不卡阈值,过两周就没人看了,这是所有质量指标的共同宿命。JaCoCo 的check目标就是干这个的:它读取 exec 数据,和设定的阈值比对,不达标就让构建失败。和report不同,check的配置里有一个rules结构,可以定义多条规则,每条规则针对一种 counter(度量维度)和一个阈值。理解清楚counter和value的对应关系,是配好这一块的关键。6.1 counter、value 与 ratio 的取值含义check的规则里,counter可选的值有:INSTRUCTION、BRANCH、LINE、COMPLEXITY、METHOD、CLASS。value可选COVEREDRATIO、MISSEDCOUNT、COVEREDCOUNT、TOTALCOUNT。日常用得最多的是前两个。countervalue含义建议用途LINECOVEREDRATIO行覆盖比例兜底,阈值可以低一些BRANCHCOVEREDRATIO分支覆盖比例核心业务逻辑的质量参考CLASSMISSEDCOUNT完全未覆盖的类数量防止新增大片死代码METHODMISSEDCOUNT未覆盖方法数同上,粒度更细minimum的取值是 0 到 1 之间的小数,注意是小数不是百分比,写0.6表示 60%,写成60会让构建永远失败。还有一个容易忽略的点:规则可以加element指定作用粒度,可选BUNDLE(整个项目)、PACKAGE、CLASS、SOURCEFILE、METHOD。默认是BUNDLE。粒度越细越容易误伤,比如按CLASS卡 80%,那你新写的任何一个工具类只要没测全就直接失败,团队会疯。6.2 一份能落地的规则配置下面这份配置是我用下来觉得比较平衡的版本,放在verify阶段执行:execution idcheck/id phaseverify/phase goals goalcheck/goal /goals configuration dataFile${jacoco.exec.path}/dataFile rules rule elementBUNDLE/element limits !-- 分支覆盖整体不低于 50% -- limit counterBRANCH/counter valueCOVEREDRATIO/value minimum0.50/minimum /limit !-- 行覆盖整体不低于 60% -- limit counterLINE/counter valueCOVEREDRATIO/value minimum0.60/minimum /limit !-- 完全没覆盖的类不超过 5 个 -- limit counterCLASS/counter valueMISSEDCOUNT/value maximum5/maximum /limit /limits /rule /rules /configuration /execution注意这里同时用了minimum和maximum。比例类的用minimum(不低于),计数类的用maximum(不超过)。这个区分很多人第一次会搞混,写成minimum加MISSEDCOUNT就变成了未覆盖类至少 5 个,语义完全反了。想让卡口可以临时关闭(比如紧急修复时),可以给 configuration 加一个skip${jacoco.check.skip}/skip,然后在 properties 里定义jacoco.check.skipfalse。需要跳过时命令行加-Djacoco.check.skiptrue即可。6.3 阈值定多少才不会被团队骂这是个人情问题,不是技术问题。定高了大家骂你,定低了指标形同虚设。我实践下来比较可行的路径是:先量后定:在完全不设卡口的情况下跑一周,看看现有的真实数字是多少,然后在这个基础上加 3 到 5 个百分点作为初始阈值。不要对着行业标准 80%拍脑袋。只卡增量不卡存量:老代码的历史包袱不该由新规则承担。如果你的流水线能区分变更模块,优先对变更部分做卡口,存量部分先挂个观察指标。比例与计数结合:纯比例考核容易催生给 getter 写无意义测试凑数的行为,配一条CLASS的MISSEDCOUNT规则,能抑制这种倾向。失败信息要可读:check失败时默认输出比较简略,可以在 configuration 里加haltOnFailuretrue/haltOnFailure保证构建真的中断,而不是打出警告就继续。这一项默认就是 true,但有的团队会手动关掉,那就失去意义了。注意:check的排除规则和report是各自独立的。你在report里排除了**/dto/**,在check里也要再写一遍,否则被排除的类会重新进入分母,导致卡口意外失败。这一条我在第 3.5 节提过,但因为后果比较隐蔽,这里再强调一次。7. 提速与降噪:让覆盖率跑起来不那么烦配置全跑通之后,新的问题会浮现:全量跑一次clean verify要七八分钟,谁都不愿意在提交前等这么久。这一章讲怎么把这件事的成本降下来,以及几个我日常在用的小经验。7.1 只跑变更模块:把构建时间砍掉一大半多模块项目里,全量构建的时间主要花在没改动的模块上。Maven 的-pl加-am组合能显著改善:# 只构建变更的模块及其依赖,顺带跑覆盖率 mvn clean verify -pl module-a,module-b -am -DskipITs-pl指定要构建的模块列表,-am表示同时构建这些模块依赖的模块。注意-am是必须的,否则上游模块没构建,依赖解析会失败。反过来的-amd是同时构建依赖这些模块的模块,在只想看下游影响时用。这个组合的副作用是:聚合报告不完整。如果上游模块被跳过,它们的 exec 数据不存在,聚合出来的整体数字会偏低。我的处理方式是,日常开发用-pl快速验证,提交前或者流水线上跑一次全量,两个数字分开看,不混用。7.2 报告归档:让覆盖率有历史可比性单次的覆盖率数字意义有限,真正有价值的是趋势。我在流水线上会把每次构建的 HTML 报告目录和 XML 文件按构建号存一份,不需要多复杂,一个拷贝命令就够。积累一段时间之后,你能看出哪些模块在恶化、哪些改动引入了大面积未覆盖代码。XML 文件通常只有几百 KB,存储成本可以忽略。相比之下,HTML 目录会大一些,如果存储紧张,只留 XML,需要看细节时再用本地的 HTML。另外,如果你在用静态扫描平台,大部分都支持直接读取 JaCoCo 的 XML 报告。配置项通常叫覆盖率报告路径,填一个指向target/site/jacoco/jacoco.xml的路径即可。这里有个前提:XML 格式必须在report目标里显式开启,默认只生成 HTML,这是很多人接了平台却看不到覆盖率数据的原因。7.3 我平时用的几条经验最后分享几个零碎但实用的点,都是踩过之后才记住的。启动入口类一定要排除。main方法所在的那个类,通常是几行启动代码,写测试要么没法测,要么测了也没意义。不排除的话,一个类就是 100% 未覆盖,对整体比例影响不小。内部类和匿名类会被单独统计。一个类里如果有匿名内部类,报告里会出现Outer$1这样的条目。如果这些内部类是回调或者监听器,测试很难直接触达,可以在excludes里用**/*$*之类的模式看情况处理。但不要无脑排掉所有内部类,有些内部类承载了核心逻辑,排掉就失去了监控意义。接口和抽象类没有指令,不会出现在覆盖率分母里,这一点不用操心。但枚举要注意,枚举的values()、valueOf()是编译器生成的,JaCoCo 对它们有专门的过滤,一般不会误伤。测试代码本身不要被统计进去。默认配置下 surefire 只跑src/test/java里的代码,JaCoCo 的 agent 会加载所有被 JVM 加载的类,包括测试类本身。如果发现报告里出现了测试类,说明 class 输出目录被一起扫描了,检查一下report的classDirectory配置有没有指错。本地开发不要每次都跑check。卡口是给流水线用的,本地频繁失败会打断节奏。前面提到的-Djacoco.check.skiptrue记得配上,开发时跳过,提交前再跑一次完整的。改完配置后对比一下报告里的类数量。这是我最常用的自检手段:排除规则加上之后,报告里的类数量应该减少;如果没有变化,说明excludes的路径模式写错了。这个检查比盯着百分比看有效得多,因为百分比会受分母影响,类数量是绝对值。我最初的几次配置都栽在以为生效了其实没生效上,后来养成习惯,每次改完排除规则先数类数量,再谈覆盖率,效率高了不少。