四、JaCoCo插件配置(Gradle):在build.gradle中配置JaCoCo插件,自定义任务

咱们直接进入实战。Gradle 项目里配置 JaCoCo,说白了就两步:引入插件,再搞定你自己的任务。我记得刚接手一个遗留项目时,团队的构建脚本乱得像毛线团,跑覆盖率报告要手动敲命令——嗯,后来我把这一套标准化了,大家只要跑一个任务就能拿到增量报告。

4.1 标准插件 vs 自定义任务

先说说标准做法。JaCoCo 有官方 Gradle 插件,你只要在 build.gradle 里加上:

plugins {
    id 'java'
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.11"
}

jacocoTestReport {
    dependsOn test
    reports {
        xml.required = true
        html.required = true
        csv.required = false
    }
}

这样就能用 ./gradlew jacocoTestReport 生成覆盖率报告了。简单吧?但我在项目中遇到过一个问题:标准任务只跑全量覆盖率,你没法单独控制“哪些类要测”、“哪些类跳过”。尤其是做大项目时,全量跑一次要十几分钟,谁等得起?

4.2 自定义 JaCoCo 任务的核心套路

自定义任务的原理其实很直白——你手动创建一个 JacocoReport 类型的任务,然后把你要分析的类目录、源码目录、执行数据文件喂给它。你想分析哪个模块、哪个包,都由你说了算。

关键点:自定义任务依赖 .exec 执行数据,这个数据来自测试运行。所以你得先跑测试,再生成报告。

来看看我常用的模板:

task incrementalCoverageReport(type: JacocoReport) {
    // 依赖所有测试任务
    dependsOn test

    // 只分析 src/main/java 下的文件(排除测试代码)
    sourceDirectories.setFrom(files("$projectDir/src/main/java"))
    // 只检查特定的包:com.example.changed
    classDirectories.setFrom(
        fileTree(dir: "$buildDir/classes/java/main",
                 include: ['com/example/changed/**'])
    )

    // 指定 .exec 文件(测试执行后的覆盖率数据)
    executionData.setFrom(file("$buildDir/jacoco/test.exec"))

    // 输出格式:HTML + XML
    reports {
        xml.required = true
        html.required = true
        html.outputLocation = layout.buildDirectory.dir("reports/jacoco/incremental")
    }
}

这里我做了两件事:

  • 通过 classDirectories 限定只分析 com.example.changed 包下的类。
  • 把输出目录单独指定,避免和全量报告混在一起。

我的经验:一开始我全量跑,后来发现每天改动的文件就那么几个。自定义任务配上 Git diff 来动态筛选,时间从 5 分钟降到 30 秒。你想想,效率提升 10 倍,队友都跑来问怎么做到的。

4.3 增量覆盖率任务的典型配置

下面给出一套完整可用的配置,它动态获取 Git 仓库中本次变更的 Java 文件,然后只对这些文件生成覆盖率报告。

import org.ajoberstar.grgit.Grgit

def getChangedClasses() {
    def repo = Grgit.open(currentDir: project.rootDir)
    def diff = repo.diff() // 与最新提交对比
    
    // 只保留 .java 文件,并转为类路径格式
    return diff.files
        .findAll { f -> f.newPath?.endsWith('.java') }
        .collect { f ->
            f.newPath
                .replace('src/main/java/', '')
                .replace('.java', '')
                .replace('/', '.')
        }
}

task incrementalReport(type: JacocoReport, group: 'Coverage') {
    dependsOn test
    
    def changed = getChangedClasses()
    if (changed.isEmpty()) {
        logger.warn('没有变更的 Java 文件,跳过覆盖率报告')
        return
    }
    
    sourceDirectories.setFrom(files("src/main/java"))
    classDirectories.setFrom(
        fileTree(dir: "$buildDir/classes/java/main",
                 include: changed.collect { it.replace('.', '/') + '.class' })
    )
    executionData.setFrom(file("$buildDir/jacoco/test.exec"))
    
    reports {
        xml.required = true
        html.required = true
        html.outputLocation = file("$buildDir/reports/incremental")
    }
}

这段代码依赖 Grgit 库,需要在 build.gradle 顶部引入:

buildscript {
    repositories { mavenCentral() }
    dependencies { classpath 'org.ajoberstar:grgit:5.2.2' }
}

为什么要用 Grgit?因为我直接用 git diff 命令解析起来太蛋疼,而且跨平台容易出 bug。Gradle 生态里 Grgit 是最成熟的 Git 操作库,你不妨试一试。

避坑指南:我曾经在 CI 环境里忘记配置 Grgit.open() 的当前目录,导致总是拿不到变更文件。务必在 currentDir: project.rootDir 明确指定路径。另外,CI 上如果只拉取了浅层 clone(--depth=1),diff 会失败。解决办法是用 git fetch --unshallow 或设置 fetch-depth: 0。

4.4 多模块项目的配置方式

如果你的项目是微服务或者多模块结构,需要在根项目和子模块间传递 .exec 文件。看下面的示意图就明白了:

多模块增量覆盖率报告流程 根项目 build.gradle 子模块 A 子模块 B 子模块 C 聚合增量覆盖率任务 HTML / XML 报告 每个子模块单独生成 .exec 文件,根任务从子模块收集数据后合并分析

上面这张流程图画出了每个子模块运行自己的测试,产生 .exec 文件。根项目里写一个 JacocoReport 任务,配置 executionData 指向所有子模块的输出:

task aggregatedIncrementalReport(type: JacocoReport) {
    dependsOn subprojects.test

    // 收集所有子模块的 .exec 文件
    executionData = files(
        subprojects.collect { it.file("$buildDir/jacoco/test.exec") }
    )

    // 包含所有子模块的源码目录
    sourceDirectories = files(
        subprojects.collect { it.file("src/main/java") }
    )

    // 从每个子模块的 classes 目录中筛选变更类
    classDirectories = files(
        subprojects.collect { project ->
            fileTree(dir: project.file("$buildDir/classes/java/main"),
                     include: getChangedClasses(project))
        }
    )

    reports {
        html.required = true
        html.outputLocation = file("$buildDir/reports/aggregated-incremental")
    }
}

这里要注意,getChangedClasses(project) 需要针对每个子模块单独计算 Git diff,因为不同模块的变更文件路径前缀不同。我一般把它拆成一个独立函数,传入 project 对象。

4.5 自定义任务的常见参数表

参数类型说明我常用的推荐值
sourceDirectoriesFileCollectionJava 源码目录,用于报告里显示代码行files("src/main/java")
classDirectoriesFileTree编译后的 .class 文件目录,可设 include/exclude根据变更类动态生成 include 列表
executionDataFileCollection.exec 或 .ec 文件,测试运行时生成的覆盖率数据file("$buildDir/jacoco/test.exec")
reports.html.outputLocationDirectoryHTML 报告输出目录layout.buildDirectory.dir("reports/jacoco/incremental")
reports.xml.requiredboolean是否生成 XML 报告true(方便 CI 解析)

4.6 几个实用小技巧

  • 跳过无变更的模块:在子模块的 tasks 里加点条件,比如 onlyIf { !getChangedClasses().isEmpty() },避免每次跑一堆空任务。
  • 一次性拿到所有 .exec:我喜欢在根项目定义 task collectExec(type: Copy) { from subprojects.collect { it.file("$buildDir/jacoco") }; into "$buildDir/allExec" },然后自定义报告依赖这个复制的任务。
  • 区分本地和 CI:本地跑增量,CI 跑全量。用 project.hasProperty('incremental') 来开关,./gradlew check -Pincremental 就触发增量模式。

说到底,自定义 JaCoCo 任务的核心能力就是让你精确控制“测什么、看什么”。配合 Git diff 后,每次提交反馈速度飞起。我个人一直在用这种方式,开发效率和覆盖率质量两头都抓住。