1. 为什么要聚合Jacoco XML单模块覆盖率数字的欺骗性先聊一个让不少人栽过跟头的问题。你有一个多模块项目查了各服务的Jacoco报告看起来每个模块覆盖率都还不错75%、82%、69%但把整个项目看成一个整体时你发现核心链路的覆盖率可能存在巨大黑洞。原因很简单每个服务只报告自己那个模块的代码覆盖情况跨模块调用、公共模块、被多个服务依赖的基础库这些代码根本没有被计入任何一个模块的报告里。我从两年前开始接手一个多模块工程刚开始也是按单模块的方式跑Jacoco每次CI出报告只看自己负责的那个服务。直到有次做架构调整要动一个公共的加密组件我下意识想确认这个组件被哪些场景覆盖到了。结果翻遍所有模块报告发现要么压根没统计它要么把它算进了某个模块的私有代码里。那一刻我才意识到单模块覆盖率是为局部优化服务的而你要回答整个系统的测试质量这个问题时必须做全局聚合。所谓的聚合各个服务的Jacoco XML报告本质要解决的是这样一个问题把分布在各个子模块、各个服务构建产物里的覆盖率数据全部收拢到一份报告里。这份报告要能回答三件事整个项目所有代码的总体覆盖率是多少。哪些公共模块是被跨服务调用但从未被充分测试的。某一次全量回归测试到底动了哪些模块、覆盖效果如何。聚合不是简单的把多个XML拼在一起。Jacoco的XML报告和exec数据是两个层次的东西exec文件是原始的覆盖率执行数据XML是经过处理的报告产物。聚合时你既可以直接合并exec文件再重新生成报告也可以直接对已有的XML报告做汇总。标题里说的聚合各个服务的Jacoco XML报告走的是后一条路——不重新跑测试、不重新生成exec数据只是把现存各模块的XML报告合并输出一份总的报告。这个场景通常在哪些情况下出现最常见的就是存量项目改造。老项目里各服务早就有各自的Jacoco配置甚至已经接入了各自的CI流程和SonarQube项目。你不可能要求所有服务推倒重来统一配置对于这种情况正确的做法就是在根项目或者一个专门的聚合模块里写一段build.gradle代码把各服务产出的XML全部引用进来统一生成全局覆盖报告。还有一种是多仓库聚合。公司里有好几个代码仓库每个仓库独立构建、独立出报告但老板要看的是整体发布链路的覆盖率。这时候你可以把其他仓库产出的Jacoco XML报告拷贝到一个约定目录下然后在统一的聚合工程里引用这些XML。相比重新构建所有服务这种方式的成本几乎可以忽略。所以你看聚合XML报告的核心价值是不改动各子模块现有配置用最小的侵入成本得到全局视角。这个思路在微服务拆分之后几乎是刚需。2. 聚合方案选型全聚合与根聚合的取舍要在Gradle里聚合Jacoco报告业内常见的有三种做法。我逐个说下它们的原理和适用场景你根据自己项目的情况选就行。第一种各子模块生成报告根项目只做汇总。每个子模块在自己的build.gradle里配置好Jacoco插件正常跑test任务正常生成各自的jacocoTestReport。根项目或者专门的聚合模块里配置一个聚合报告任务通过dependsOn依赖所有子模块的jacocoTestReport任务然后基于子模块产出的XML和exec文件用JacocoReport类型的任务再生成一份总报告。这种方案的优点是结构清晰每个子模块保持自治聚合模块只关心收集和汇总。缺点也很明显——如果子模块数量很多构建时间会变长而且如果某个子模块不是Java项目比如是Kotlin或者Scala混编你需要额外处理sourceSets的映射。第二种所有模块共享一个Jacoco配置统一在根项目执行测试。在根项目的build.gradle里统一配置Jacoco插件通过subprojects或allprojects把配置下发到每个子模块。执行测试时每个子模块产出exec文件最终在根项目统一合并exec数据并生成报告。这个方案的好处是整个项目的配置非常集中一次改动全局生效而且可以直接拿到第一手的exec数据聚合结果最准确。缺点是对已有项目的侵入性很强——如果各子模块已经用了不同版本的Jacoco或者有些模块有特殊的Java版本要求统一配置会引发一堆兼容性问题。第三种不通过Gradle任务直接合并已有的XML报告文件。这也算一种伪聚合。把各服务的XML报告拷贝到一个目录下然后通过脚本读取这些XML里的数据做汇总输出一份新的覆盖率统计。对的你没看错纯文件层面的合并不依赖Gradle。这其实也是一种很实用的兜底方案——尤其是别人给了你一份XML报告你得把它合并进自己的数据里的时候。三种方案对比下来我的结论是如果你是在一个多模块工程内部做聚合选第二种统一配置最省心如果你是要整合多仓库或历史遗留的XML报告选第三种用代码直接合并XML如果你既不希望改动子模块又希望聚合逻辑可视化地存在于构建流程中选第一种根项目依赖子模块的报告任务。标题里提到的那段在build.gradle中您可以使用以下代码来聚合各个服务的Jacoco XML报告指的其实就是第一种或第三种方案的结合体——在根项目或专用聚合模块里写一段代码引用各子模块产出的XML文件。接下来我给你一份可以直接用的完整配置。3. build.gradle聚合代码拆解merge与report如何配合先直接上一份能跑的配置。下面这个例子假设你的项目结构是这样的root-project/ build.gradle settings.gradle module-a/ build.gradle module-b/ build.gradle module-c/ build.gradlesettings.gradle里正常include三个子模块。现在在根项目的build.gradle里加上聚合配置。先看核心的build.gradle内容// 根项目 build.gradle plugins { id java id jacoco } // 所有子模块统一使用 Java 和 Jacoco 插件 allprojects { apply plugin: java apply plugin: jacoco // 统一 Jacoco 版本建议使用较新的稳定版 jacoco { toolVersion 0.8.11 } // 每个子模块生成 XML 报告 test { finalizedBy jacocoTestReport } jacocoTestReport { dependsOn test reports { xml.required true html.required false csv.required false } } } // 根项目下新增聚合报告任务 def projectNames subprojects.collect { it.name } task jacocoMergeReport(type: JacocoReport) { // 依赖所有子模块的 jacocoTestReport 任务确保先执行 dependsOn subprojects.jacocoTestReport // 关键配置指定所有子模块的 class 目录和 source 目录 def classDirs files(subprojects.collect { ${it.buildDir}/classes/java/main }) def sourceDirs files(subprojects.collect { ${it.projectDir}/src/main/java }) // 聚合所有子模块的 exec 数据 executionData files(subprojects.collect { ${it.buildDir}/jacoco/test.exec }) // 汇总每个子模块的 XML 报告 def xmlReports files(subprojects.collect { ${it.buildDir}/reports/jacoco/test/jacocoTestReport.xml }) sourceDirectories.setFrom(sourceDirs) classDirectories.setFrom(classDirs) executionData.setFrom(xmlReports) // 注意这里官方文档说 executionData 是 exec 文件但我们这里放的是 XML 报告 reports { xml.required true html.required true csv.required false } }别急着复制上面这段有一个故意埋的坑。executionData的正确用法是放.exec文件而不是XML报告。如果直接把XML路径塞进去运行时大概率会报错告诉你数据格式不匹配。所以这里需要上下两段配置配合使用task jacocoAggregateReport(type: JacocoReport) { dependsOn subprojects.jacocoTestReport // 收集所有子模块的 class 目录和 source 目录 def classDirs files(subprojects.collect { ${it.buildDir}/classes/java/main }) def sourceDirs files(subprojects.collect { ${it.projectDir}/src/main/java }) sourceDirectories.setFrom(sourceDirs) classDirectories.setFrom(classDirs) // 方式一直接合并所有子模块的 exec 数据推荐 executionData.setFrom(files(subprojects.collect { ${it.buildDir}/jacoco/test.exec })) reports { xml.required true html.required true } }但是标题明确说的是聚合各个服务的Jacoco XML报告也就是手里只有各服务的XML没有原始exec文件。这种情况下的聚合就不能直接用Gradle原生的JacocoReport任务去吞XML了。原因很直接JacocoReport的executionData期望的是二进制exec格式XML不是它认识的输入类型。你要么先反序列化XML数据再塞回去要么走另一条路——用JacocoMerge合并exec再在report阶段引用合并结果。这时候正确的姿势是分两步走第一步让各子模块在生成XML报告的同时保留exec文件第二步根项目的聚合任务先合并所有exec文件再统一生成报告。// 第一步合并所有子模块的 exec 文件 task jacocoMerge(type: JacocoMerge) { dependsOn subprojects.jacocoTestReport executionData files(subprojects.collect { ${it.buildDir}/jacoco/test.exec }) destinationFile file(${buildDir}/jacoco/merged.exec) } // 第二步基于合并后的 exec 生成总报告 task jacocoAggregateReport(type: JacocoReport) { dependsOn jacocoMerge def classDirs files(subprojects.collect { ${it.buildDir}/classes/java/main }) def sourceDirs files(subprojects.collect { ${it.projectDir}/src/main/java }) sourceDirectories.setFrom(sourceDirs) classDirectories.setFrom(classDirs) executionData.setFrom(files(${buildDir}/jacoco/merged.exec)) reports { xml.required true html.required true } }到这里你会发现一个尴尬的问题如果aggregateReport依赖了jacocoTestReport而jacocoTestReport只生成XML不保留exec那merged.exec可能是空的。解决方案是让每个子模块在jacocoTestReport的同时额外生成一份exec数据或者直接把merge的executionData改成读取各模块jacocoTestReport的输入。讲到这里必须说清楚一件事真正稳定、被Jacoco官方插件支持的聚合方式聚合的对象永远是exec文件聚合完成后再统一生成各种格式的报告。XML只是最终报告的一种格式不是中间交换格式。所以在Gradle里聚合Jacoco XML报告这句话其实要翻译成收集各模块的覆盖率数据合并后生成一份包含完整信息的XML报告。如果你非要直接把多个XML文件拿来合并成另一个XML那需要自己写XML解析逻辑也就是下一节要讲的另一个思路。4. 用Groovy或Python直接合并XML纯文件层面的兜底方案既然标题提到了聚合各个服务的Jacoco XML报告我还是把这个直接操作XML文件的方案也讲透。因为在实际工作里有些场景你真的拿不到exec文件只有别人甩给你的一堆XML报告尤其当服务是部署在远程、由CICD固化了整个构建流程的时候。Jacoco的XML报告结构其实很有规律。根节点是report下面有sessioninfo、groupgroup里嵌套packagepackage里有class和sourcefileclass和sourcefile里是一行行counter节点。覆盖率的数据最终都落在counter节点上每个counter都有type和value两个属性。看一个典型的片段我只是为了讲结构你不需要运行report namemodule-a package namecom/example/controller class namecom/example/controller/UserController method namelogin desc(Ljava/lang/String;)V counter typeINSTRUCTION missed12 covered88/ counter typeLINE missed3 covered9/ counter typeCOMPLEXITY missed2 covered4/ counter typeMETHOD missed0 covered1/ /method counter typeINSTRUCTION missed120 covered880/ counter typeLINE missed15 covered63/ counter typeCOMPLEXITY missed8 covered21/ counter typeMETHOD missed1 covered12/ /class /package /report所谓合并本质就是把多个这样的XML按package和class维度对齐把相同类的counter值做累加。累加时要注意的不是简单地数值相加而是missed和covered分别相加然后总行数、总指令数、总圈复杂度都基于累加后的数据重新计算分支覆盖率。我写过一段Groovy脚本直接干这件事。之所以选Groovy而不是Python是因为它在Gradle环境里可以直接复用不用额外起进程。核心部分长这样// mergeJacocoXml.groovy import groovy.xml.XmlUtil import javax.xml.parsers.DocumentBuilderFactory def reportDir new File(reports) def mergedCounters [:] // 递归读取所有 jacoco XML 报告 reportDir.eachFileRecurse(groovy.io.FileType.FILES) { file - if (file.name.endsWith(jacocoTestReport.xml)) { def doc DocumentBuilderFactory.newInstance() .newDocumentBuilder() .parse(file) def counters doc.getElementsByTagName(counter) for (int i 0; i counters.length; i) { def counter counters.item(i) def type counter.getAttributes().getNamedItem(type).nodeValue def missed Integer.parseInt(counter.getAttributes().getNamedItem(missed).nodeValue) def covered Integer.parseInt(counter.getAttributes().getNamedItem(covered).nodeValue) def key ${counter.parentNode.parentNode.getAttributes().getNamedItem(name).nodeValue}|${counter.parentNode.getAttributes().getNamedItem(name).nodeValue}|${type} if (!mergedCounters.containsKey(key)) { mergedCounters[key] [missed: 0, covered: 0] } mergedCounters[key].missed missed mergedCounters[key].covered covered } } } // 按需输出合并结果也可以用 XML 格式输出 mergedCounters.each { key, value - println ${key}: missed${value.missed}, covered${value.covered} }这段脚本足够演示聚合计数的原理。但真实使用时你不希望自己维护这套XML解析逻辑否则每次Jacoco升级改一下XML结构你就得跟着改。所以更推荐的做法是用ant.importBuild把Jacoco的ant任务引入Gradle用jacoco:merge和jacoco:report任务处理前者吃XML后者输出合并报告。听起来有点绕但确实可行而且我一直认为这是最接近Gradle原生聚合的曲线方案。不过说句实在话如果项目里已经统一用了org.jacoco插件我建议直接采用上一节讲的exec聚合方案没必要碰XML文件级的合并。只有当你需要整合的项目还没有纳入同一个Gradle工程时文件级合并才有意义。比如第三方仓库给你提供的是XML报告你的聚合工程只需要读取它、并进自己的总报告里。5. 覆盖率被稀释的排查链路从数据异常到根因定位讲了这么多配置接下来进入我认为这篇博文最有价值的部分——聚合报告产出了但你一眼就发现数据不对这时候怎么排查。先说一个我真实踩过的坑。改造完聚合报告后的第一次构建总覆盖率跑出来只有23%。我当时第一反应是Jacoco聚合配置写错了exec文件没收集全。于是开始排查顺着这条链路一路查下去。第一步先检查聚合报告里到底包含了哪几个模块的class。一个很隐蔽的坑是classDirectories.setFrom(sourceDirs)里遗漏了某些子模块的build目录或者路径拼错了导致某些模块的class根本没进入统计范围。覆盖率低有时候不是测试跑的少而是统计的基数变大了——因为聚合把所有模块的所有class都算进了分母而各个单模块报告只统计自己测试执行过的class。第二步检查executionData合并是否有遗漏。Project里有子模块因为特殊原因没跑test任务直接导致它的exec文件不存在那合并时Gradle可能直接跳过也可能报错。这要看你是用files(subprojects.collect { ... })还是fileTree。fileTree有个好处是能容忍缺失路径但坏处是它可能把不想合并的旧数据也带进来。第三步也是最容易忽略的——重复计算导致的class污染。某业务模块的代码被A服务打包时带进去了在B服务的聚合报告里又出现了同一份class两个exec里都记录了这部分的执行数据。合并计算时这份class的覆盖数据被算了两遍看起来好像覆盖率不错但如果你用LINE和BRANCH粒度对账会发现同一行代码的missed和covered冲突最终报告会把覆盖结果算成加权后的中间值导致数据失真。我当时排查到最后发现根因根本不在这些地方。真正的元凶是子模块的build.gradle里有人配置了jacocoTestReport { reports { xml.required true } }但这个任务依赖的test任务被exclude掉了某些测试类导致exec数据不完整。聚合报告把所有模块数据混在一起后这个问题被放大原来单模块报告里每个模块只统计自己的测试看起来覆盖面还行聚合后某些模块的真实测试量就原形毕露了。这个案例想说明什么呢聚合报告放大问题但也能暴露问题。当你看到聚合覆盖率明显低于任何一个单模块报告时不要急着怀疑脚本写错了。先做一次数据对账单独看几个重点模块的exec数据再用jacocoReport任务单独生成它们的报告和单模块报告比对。如果两份报告不一致说明这个模块的exec采集本身就有问题跟聚合逻辑无关。另一个排查高频问题XML报告的路径配置成绝对路径了团队其他人拉代码运行时路径失效。我的建议是聚合配置里所有路径都用相对路径拼Gradle变量例如${it.buildDir}/jacoco/test.exec。这个it是Project对象buildDir是它自带的属性在configuration阶段就会解析成正确的绝对路径所以不用担心。聚合配置还会遇到一个让你抓狂的场景某个子模块根本是纯工具库没有任何测试。它会生成一份test.exec但内容是空的。合并exec时空文件不会报错但如果你用JacocoReport直接引用它可能会导致report任务失败。稳妥的做法是在合并之前过滤掉不存在的exec文件或者排除不应用Java插件的子模块。一个我在生产环境验证过的过滤写法def validExecs subprojects.findAll { it.plugins.hasPlugin(java) }.collect { file(${it.buildDir}/jacoco/test.exec) }.findAll { it.exists() it.length() 0 }这个写法先确认子模块确实应用了Java插件再检查exec文件存在并且非空。空文件过滤掉后合并逻辑就干净很多。6. 聚合后的报表配置sourceSets、classDir与sourceDir的映射关系聚合报告能做出来是一回事做出来的报告能不能用是另一回事。很多人配置完聚合打开HTML报告一看代码是高亮覆盖状态了但点击方法名跳不到源码或者显示的包名路径全是build/classes/java/main这种乱糟糟的目录这就说明sourceDirectories配错了。Jacoco报告要正常显示源码需要把sourceDirectories准确指向每个模块的源码目录把classDirectories指向编译输出目录。对于标准Java项目一般的配置是sourceDirectories.setFrom( files(subprojects.collect { ${it.projectDir}/src/main/java }) ) classDirectories.setFrom( files(subprojects.collect { ${it.buildDir}/classes/java/main }) )但如果你项目里用了Kotlin混编就必须加上src/main/kotlin目录否则所有Kotlin源码在报告里都是无源码状态。如果是多语言混合项目可以这样配sourceDirectories.setFrom( files(subprojects.collect { def srcDirs [] if (file(${it.projectDir}/src/main/java).exists()) { srcDirs ${it.projectDir}/src/main/java } if (file(${it.projectDir}/src/main/kotlin).exists()) { srcDirs ${it.projectDir}/src/main/kotlin } return srcDirs }.flatten()) )还有一个更隐蔽的问题如果你用的是增量编译或Gradle的BuildCachebuild/classes/java/main下可能是最新的一次编译产物但它不一定和exec文件里记录的classId完全对应。Jacoco是根据class文件的唯一指纹classId匹配exec数据的如果源码改动了classId变了旧exec数据可能对不上新class这时报告里会出现stale data或大面积的miss。解决这个问题的关键点是确保聚合报告的classDir和exec数据来自同一次构建。不要让聚合任务依赖一个很久以前构建的class目录也不要让exec文件来自另一个分支的构建。这也是为什么我建议聚合任务要dependsOn subprojects.jacocoTestReport而不是直接手动指定文件路径——任务依赖能保证执行的时序。如果你服务之间的构建产物是各自独立拉取到统一目录的比如CI里把多个服务的build目录直接archive上传再聚合那么你要小心CI缓存导致的class过期。我遇到过最头疼的情况A服务的构建产物被CI缓存第二次构建虽然重跑了测试但build/classes是缓存的旧产物exec是新数据聚合出来的报告一片飘红。排查时一度以为是Jacoco插件的问题最后发现是CI缓存的锅。所以聚合报告这条链路里数据的新鲜度一致性比什么都重要。再来聊聊sourceSets。在比较复杂的多模块场景里子模块可能自定义了额外的sourceSet比如integrationTest、apiTest。如果你想把这些测试的覆盖率也聚合进总报告单靠jacocoTestReport是不够的还要额外配置// 在子模块里 task jacocoIntegrationTestReport(type: JacocoReport) { dependsOn integrationTest executionData integrationTest sourceDirectories.setFrom(sourceSets.main.allSource.srcDirs) classDirectories.setFrom(sourceSets.main.output) reports { xml.required true } }然后在聚合任务里把这个jacocoIntegrationTestReport也加进依赖task jacocoAggregateReport(type: JacocoReport) { dependsOn subprojects.jacocoTestReport dependsOn subprojects.jacocoIntegrationTestReport ... }这样报告的统计口径会变成单元测试集成测试的综合覆盖率。但要注意这样会让报告的数字偏乐观通常集成测试跑的场景更真实、覆盖更充分。如果你要的是纯粹的单元测试覆盖率就不应该把集成测试数据混进来。关于报告口径选择我的建议是明确在CI里区分两个任务一个纯单元测试覆盖率一个全量测试覆盖率含集成测试。这两个数据在发布评估时作用不同前者看代码质量趋势后者看发布前的整体回归验证程度。混在一起会让你做质量决策时失去维度。7. 聚合数据的进阶玩法基线对比与增量覆盖门禁聚合并不仅仅是为了出一份报告给汇报用。报告是给人看的但覆盖率数据更重要的价值是喂给自动化质量门禁。把你聚合出来的XML或exec数据接到质量门禁里可以做三件很有价值的事情。第一件事基线对比。把每次聚合作业生成的XML报告存档下一次构建时自动对比。对比的维度不应该是总覆盖率是否比上次高0.5%这个数字太宏观看了等于没看。要对比的是增量代码的覆盖率——本次提交里新增、修改的那些代码行覆盖率是不是达到了阈值。做不到这一点覆盖率数字涨涨跌跌根本不能反映代码质量变化。第二件事按模块设不同门禁。公共模块、核心交易链路模块、工具类模块这三类代码对覆盖率的要求应该完全不同。聚合报告里的package维度数据可以细分到每个子模块因此可以在门禁脚本里配置核心域模块类覆盖率 85%行覆盖率 80%公共组件模块指令覆盖率 70%纯工具模块方法覆盖率 50%不要求行覆盖这些诉求用Jacoco原生的JacocoReport任务很难直接实现因为它是面向整体报告的。推荐的姿势是把聚合后的数据导出成XML然后用脚本解析XML按包名前缀匹配对应的模块再计算各自的覆盖率。这就是为什么聚合任务一定要生成XML报告——它不仅是给人看的更是给质量门禁程序消费的。第三件事与SonarQube集成。SonarQube的sonar.jacoco.reportPaths属性支持配置多个XML路径你也可以直接指向聚合任务生成的XML。但注意SonarQube的覆盖率是按每个模块独立计算的它内部会为每一个sonar.module生成一个覆盖数据条目。如果你把聚合XML直接塞给Sonar它不能识别出同一个包在多个模块的重复数据会导致重复计算。正确做法是在Sonar侧保留各模块的独立XML聚合报告只用于可视化整体大盘不进Sonar的代码质量计算。如果你用的是GitLab CI或者Jenkins可以把聚合任务生成的HTML报告作为artifact上传这样MR或者构建页面里就能直接点击查看全量报告。这里有一个经验HTML报告要按日期加后缀命名避免上一份构建的旧报告积累在GitLab的artifact列表里时间一长分不清哪份是哪份。我还试过把聚合报告接进企业内部的Dashboard。数据源就是聚合出来的XML我在Dashboard上展示三个指标全项目行覆盖率、核心业务包覆盖率、新增代码行覆盖率。实践下来真正能促进团队改进的是新增代码行覆盖率这个指标因为它逼着开发在提交新代码的时候顺手写测试。而这个指标恰恰必须基于聚合数据才能算——单模块报告根本看不了全局的新增代码。8. 一段可以直接套用的完整聚合代码讲了一堆原理和坑最后上一段我目前在生产环境里用的聚合配置你直接复制改路径就能跑。根项目的build.gradleplugins { id java id jacoco } allprojects { apply plugin: java apply plugin: jacoco jacoco { toolVersion 0.8.11 } } subprojects { test { finalizedBy jacocoTestReport } jacocoTestReport { dependsOn test reports { xml.required true html.required false csv.required false } } } // ---------- 聚合任务 ---------- def allSubprojectClassDirs subprojects.findAll { it.plugins.hasPlugin(java) }.collect { file(${it.buildDir}/classes/java/main) } def allSubprojectSourceDirs subprojects.findAll { it.plugins.hasPlugin(java) }.collect { file(${it.projectDir}/src/main/java) } def allSubprojectExecFiles subprojects.findAll { it.plugins.hasPlugin(java) }.collect { file(${it.buildDir}/jacoco/test.exec) }.findAll { it.exists() it.length() 0 } task jacocoMergeExec(type: JacocoMerge) { dependsOn subprojects.jacocoTestReport executionData files(allSubprojectExecFiles) destinationFile file(${buildDir}/jacoco/merged.exec) } task jacocoAggregateReport(type: JacocoReport) { dependsOn jacocoMergeExec sourceDirectories.setFrom(files(allSubprojectSourceDirs)) classDirectories.setFrom(files(allSubprojectClassDirs)) executionData.setFrom(files(${buildDir}/jacoco/merged.exec)) reports { xml.required true xml.outputLocation file(${buildDir}/reports/jacoco/aggregate/jacocoAggregateReport.xml) html.required true html.outputLocation file(${buildDir}/reports/jacoco/aggregate/html) csv.required false } }这段配置有个细节值得注意jacocoMergeExec的dependsOn是subprojects.jacocoTestReport。语义上是先让所有子模块跑完测试并生成单模块报告再合并exec。如果你希望聚合任务能在干净的代码检查流程里独立执行就必须保证这个依赖链完整。关于exec文件为空的处理我在上文的过滤逻辑里已经做了。这样做的好处是如果一个子模块没有任何测试它不会阻断聚合任务。但有得有失——没测试的模块也从总报告里消失了总覆盖率的分母会变小。如果你希望强制展示未测试模块的0%覆盖就需要去掉这个过滤。这个取舍看团队风格我偏向保留过滤因为0%的模块出现在报告里除了吓人对数据质量没有太大帮助反而让总覆盖率失去趋势参考价值。聚合任务跑完后你会得到一份merged.exec和一份jacocoAggregateReport.xml。后续无论是接门禁脚本、上传Sonar还是做二次分析都以这份XML为准。再补充一个实战细节。如果你的子模块用的是application插件且是Spring Boot项目可能还有一层bootJar打出来的包。在聚合时不应该把libs目录里的依赖 jars 加进classDirectories否则报告会把第三方的类也统计进去导致覆盖率虚低。所以上面配置里classDirectories只取了build/classes/java/main这是最稳妥的。除非你确实想统计fat jar里的一些内部类否则别动这个配置。9. 从聚合到治理覆盖率数据怎么用才不浪费聚合报告上线一段时间后你可能会发现一些数据悖论。比如某个模块单测覆盖率65%但聚合报告里它只有40%。别急着改代码先搞清楚原因可能是该模块的代码里包含了生成类、DTO、配置类这些不可测试代码而它们在被聚合时按包维度摊进了总量。这时可以通过Jacoco的excludes配置把这类代码从聚合范围里排除掉classDirectories.setFrom( files(allSubprojectClassDirs.collect { fileTree(dir: it, excludes: [ **/dto/**, **/config/**, **/entity/**, /**/*Mapper.class, **/*Builder.class ]) }) )把DTO和配置类排除后聚合报告会老实很多数字也能反映真实的业务代码覆盖情况。但这里要小心excludes是按class的包路径匹配的写错了可能导致某个模块的类全部被误删报告直接报no classes found。配置后务必跑一次构建确认class数量没有异常缩水。还有一个治理思路是按package维度暴露未覆盖的高风险区域。聚合XML里包含了每个package的counter数据你可以写一个脚本解析XML找出INSTRUCTION的missed数最多的5个package。BRANCH的covered率最低的5个package。修改频率高但覆盖率低于平均水平的package。在团队例会上直接抛这些数据比贴一张总覆盖率数字的图有用得多。开发们看到自己的模块在missed榜上才会有动力补测试。这也是聚合数据在数据分析驱动测试改进这个方向上的正确用法。我个人特别建议你把聚合报告接入CI的失败门禁但不是卡总覆盖率而是卡增量代码覆盖率。具体做法如果当日构建的HEAD和上一次构建之间有新增代码的覆盖率低于60%构建失败。这个阈值看起来比全量覆盖率80%低很多但效果惊人——因为它直接约束了新增代码必须有测试这条纪律。聚合报告为这个门禁提供了唯一可靠的数据基础因为只有把全量数据合在一起你才能算得出一段跨模块的新增代码到底被哪些测试覆盖了。关于增量覆盖率的具体实现Jacoco本身有一个jacoco-instrument的离线模式支持基于classId做增量比对但在多模块聚合时比较麻烦。更简单的是在CI里利用sonar的sonar.coverage.jacoco.xmlReportPaths配合sonar.newCodeCoveragePlugin配置让它自动计算新增代码覆盖率。当然这些都依赖你已经正确产出了聚合XML所以根基还是那一份干净的聚合报告。最后还有一个小技巧值得分享在聚合任务执行完之后顺手生成一份merged.exec的校验值和上一次的做对比。如果两次聚合的exec差异极小但代码变更非常多那可能意味着测试并没有真正覆盖到变更后的代码——这通常是个提醒信号说明新增代码没有对应测试。校验值对比不需要复杂逻辑用MD5就够了但它能给质量治理提供一条自动化预警线比等人来问为什么覆盖率没变要主动得多。整个聚合流程跑顺之后你会发现它对项目的价值不在于那一份光鲜的总报告而在于提供了一套统一的、可对账的覆盖率数据底座。后续的增量门禁、模块治理、风险暴露全部可以基于这个底座来搭。我自己的经验是第一周花点时间把聚合链路和CI接好之后每个月能节省至少半天的人工汇总时间而且数据的可信度远高于手工拼凑。