Jacoco 覆盖率报告的配置与解读

Jacoco 覆盖率报告的配置与解读

Jacoco 覆盖率报告的配置与解读


Jacoco 覆盖率报告的配置与解读


一个被忽略的质量信号


去年维护一个老项目时,我遇到了一个典型的场景:CI 流水线里跑完测试,Jacoco 报告生成成功,覆盖率 87%,团队觉得挺满意。但线上还是出了 NPE,堆栈指向的代码路径在测试里明明"覆盖"到了。追查下去发现,那条分支只执行了 if (list != null) 的前半段,后半段 list.isEmpty() 在真实环境触发了空指针——测试数据构造得太"乖",没让 list 变成空列表,而 Jacoco 的"行覆盖"把这个分支标绿了。


这件事让我意识到,覆盖率数字本身是个粗糙的信号,而大多数 Android 项目对 Jacoco 的配置停留在"能跑就行",对报告解读更是只看那个百分比。这篇文章想聊的是:怎么把 Jacoco 配到能反映真实风险,以及怎么看报告才能避免被数字误导。


Jacoco(Java Code Coverage Library)是 EclEmma 团队维护的开源项目,GitHub 地址 github.com/jacoco/jacoco,目前最新稳定版本是 0.8.11。它通过 Java Agent 在运行时插桩字节码,记录每个方法、每条分支的执行情况。和 Cobertura 相比,Jacoco 对 Java 8+ 的 lambda、stream 支持更好;和 IntelliJ 内置的覆盖率工具相比,Jacoco 更适合 CI 集成。但 Jacoco 不是银弹,它的插桩机制在 Android 上有特殊限制,后面会具体说。


Android 项目的配置陷阱


Android Gradle Plugin 从 3.6 开始内置了 Jacoco 支持,但"内置"不等于"开箱即用"。一个常见的坑是:你在 build.gradle 里写了 testCoverageEnabled true,跑 ./gradlew createDebugCoverageReport,报告出来了,但数字明显偏低——很多实际跑过的代码显示为未覆盖。


问题出在 Android 的构建流程。AGP 对 class 文件做了一系列转换:desugar、R8/ProGuard 混淆、Jetifier 迁移 AndroidX,这些步骤会改变字节码结构。Jacoco 的插桩发生在单元测试编译后,但如果最终 APK 里的 class 和测试时插桩的 class 不一致,报告就会错乱。特别是开启 R8 的 release 构建,方法被内联、类被合并,Jacoco 的探针数据根本对不上原始源码。


我的建议是:单元测试覆盖率用 testCoverageEnabled,但只配在 debug build type 上;如果要做集成测试或功能测试的覆盖率,用 connectedCheck 任务配合 createDebugAndroidTestCoverageReport,但要确保 minifyEnabled false。对于需要测 release 构建的场景,必须配置 testProguardFile 保留 Jacoco 的 runtime 类,或者改用 offline instrumentation 模式——这个后面单独讲。


具体配置看 build.gradle(Groovy 语法):


android {
    buildTypes {
        debug {
            testCoverageEnabled true
        }
    }
    
    testOptions {
        unitTests.all {
            jacoco {
                includeNoLocationClasses = true
                excludes = ['jdk.internal.*']
            }
        }
    }
}

includeNoLocationClasses = true 是 Java 11+ 必须的,否则 Jacoco 会抛 IllegalStateException: Can't add different class with same nameexcludes 排除 JDK 内部类,避免模块化系统的访问限制。


Jacoco Gradle Plugin 的版本要显式指定,不要用 AGP 传递的默认版本。在根目录 build.gradlegradle/libs.versions.toml 里锁定:


jacoco {
    toolVersion = "0.8.11"
}

0.8.8 之前有个坑:对 Kotlin 的 inline 函数覆盖计算错误,会把内联展开后的代码重复计数。0.8.8 修复了这个问题,但 Kotlin 1.9 的 context receivers 又带来了新的兼容性问题,0.8.11 才完全解决。如果你项目在用 Kotlin 2.0 实验性功能,可能需要关注 Jacoco 的 SNAPSHOT 版本。


报告任务与合并策略


Android 项目通常有两套测试:JVM 单元测试(testDebugUnitTest)和仪器化测试(connectedDebugAndroidTest)。它们生成各自的 .exec 数据文件,默认分别放在 build/jacoco/testDebugUnitTest.execbuild/outputs/code_coverage/.../coverage.ec


很多团队的 CI 只配置其中一种,或者分别生成两份报告。更好的做法是合并数据,得到完整的覆盖画像。Jacoco 的 JacocoMerge 任务已经 deprecated,正确做法是用 JacocoReport 任务的 executionData 接收多个文件:


tasks.register('mergedJacocoReport', JacocoReport) {
    dependsOn 'testDebugUnitTest', 'connectedDebugAndroidTest'
    
    sourceDirectories.setFrom(files("$projectDir/src/main/java"))
    classDirectories.setFrom(fileTree(
        dir: "$buildDir/intermediates/javac/debug",
        excludes: ['**/R.class', '**/R$*.class', '**/BuildConfig.*']
    ))
    
    executionData.setFrom(fileTree(dir: buildDir, includes: [
        'jacoco/testDebugUnitTest.exec',
        'outputs/code_coverage/**/*.ec'
    ]))
    
    reports {
        xml.required = true
        html.required = true
    }
}

这里有几个细节。classDirectories 指向的是编译后的 class 文件,不是 APK 里的 dex。AGP 7.0 以后编译输出路径变了,旧配置会找不到文件,需要用 intermediates/javac/debug/classesintermediates/classes/debug 根据 AGP 版本调整。我通常加一个兼容性判断:


def classDir = fileTree(dir: "$buildDir/intermediates/javac/debug/classes")
if (!classDir.dir.exists()) {
    classDir = fileTree(dir: "$buildDir/intermediates/classes/debug")
}

excludes 里去掉 R 文件和 BuildConfig 是必要的,这些生成的代码不应该计入覆盖。但有些项目用 view binding 或 data binding,生成的 *-binding.java 文件也需要排除,否则覆盖率会被大量不可测的代码稀释。


仪器化测试的 .ec 文件在 Android 10+ 有变化。Google 在 API 29 引入了 adb shell instrumentation -w -r -e coverage true 的新格式,文件路径从 /data/data/<pkg>/coverage.ec 变成了应用私有目录,需要 adb shell run-as <pkg> cat ... 拉取。如果用 Firebase Test Lab 或 AWS Device Farm,还要处理远程设备的文件下载。我写过一段 Gradle task 用 android-adb 库自动拉取,但说实话不太稳定,不同厂商的 ROM 对 run-as 权限限制不同,华为和小米尤其麻烦。


四种覆盖指标的实质


Jacoco 报告里有四个数字:Instructions(指令覆盖)、Branches(分支覆盖)、Cyclomatic Complexity(圈复杂度)、Lines(行覆盖)。大多数团队只看 Lines,这个数字最直观,但也是最会骗人的。


Instructions 是 Jacoco 最基础的度量,基于 Java 字节码的指令计数。一个 Java 语句可能编译成多条字节码指令,比如 a = b + c 涉及 load、add、store 至少三条。指令覆盖高,说明代码确实被执行到了字节码层面,但它不区分执行路径。一个 if-else 两个分支都编译成指令,只走一条分支也能覆盖这条语句的大部分指令。


Branches 是我个人最关注的指标。它基于方法的控制流图,统计所有跳转分支(if、switch、三元运算符、循环条件)的覆盖情况。前面提到的 NPE 案例,行覆盖显示绿色,但分支覆盖会暴露 list.isEmpty() 那个分支从未执行。Jacoco 对分支覆盖的计算有个细节:它把异常处理也视为分支,try-catch 里的 catch 块如果没触发,会算成未覆盖分支。这有时候让人困惑,但确实反映了防御性代码的测试缺口。


Cyclomatic Complexity 不是覆盖指标,是度量指标。McCabe 复杂度计算公式是边数 - 节点数 + 2,Jacoco 在方法层面计算这个值。复杂度高的方法,要达到完全分支覆盖需要更多测试用例。我通常设置团队规则:复杂度超过 10 的方法必须强制要求 100% 分支覆盖,否则不允许合并。这个阈值可以配在 Jacoco 的 violationRules 里:


tasks.withType(JacocoCoverageVerification) {
    violationRules {
        rule {
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }
        }
        rule {
            element = 'METHOD'
            limit {
                counter = 'COMPLEXITY'
                value = 'TOTALCOUNT'
                maximum = 10
            }
        }
    }
}

Lines 覆盖是最宽松的,一条语句只要执行过就算覆盖,不管里面的表达式是否完全求值。Java 的短路逻辑 a && b,如果 a 为 false,b 没执行,行覆盖仍然算这条语句覆盖。Jacoco 的 HTML 报告用黄色标识"部分覆盖"的行,就是这个场景——绿色是全部覆盖,红色是完全没覆盖,黄色是执行过但分支未全覆盖。很多人忽略黄色,只看红绿,这是个坏习惯。


Kotlin 的特殊情况


Jacoco 对 Kotlin 的支持是"能用但有偏差"。Kotlin 编译器生成大量 synthetic 方法:data class 的 componentN()、sealed class 的 WhenMappings、inline 函数的展开代码、默认参数的 $default 重载。这些在字节码里存在,但源码里没有对应行,Jacoco 的源码映射会出问题。


一个典型现象:Kotlin 的 when 表达式,如果枚举或 sealed class 增加了新分支但没有对应 case,编译器在字节码里插 throw new NoWhenBranchMatchedException()。Jacoco 会把这个隐式分支算进去,覆盖率突然下降,而开发者看源码找不到新增代码。0.8.8 之后有所改善,但 WhenMappings 数组的初始化代码仍然会被计入,导致 class 覆盖率偏低。


inline 函数是另一个痛点。Kotlin 的 inline fun <T> T.also(block: (T) -> Unit): T,调用处会字节码内联,Jacoco 的探针插在内联前的函数体里,但执行计数分散到每个调用点。报告上 also 本身的覆盖率可能显示很低,但调用它的代码覆盖正常。我的做法是:在 Jacoco 的 classDirectories 过滤里排除 kotlin 标准库的内联函数,或者对工具类统一写集成测试覆盖。


Kotlin 的 suspend 函数和协程状态机,Jacoco 基本无法正确映射。编译器把 suspend 函数拆成多个状态类和方法,源码里一行 delay(100) 可能对应状态机的多个 label 跳转。目前 Jacoco 没有官方支持协程覆盖的方案,社区有人尝试用 Kotlin 编译器插件在 IR 层面插桩,但还没成熟工具。实际项目中,我对 suspend 函数的覆盖要求放宽到行覆盖,不强制分支覆盖。


与 CI 和 SonarQube 的集成


覆盖率数据只有在 CI 里做门禁才有意义。GitHub Actions 的示例配置:


- name: Run tests with coverage
  run: ./gradlew mergedJacocoReport

- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v3
  with:
    files: build/reports/jacoco/mergedJacocoReport/mergedJacocoReport.xml
    fail_ci_if_error: true

Codecov(codecov.io)有免费版,开源项目无限用,私有项目 5 个用户内免费。它的 PR 评论功能很实用,会标出这次变更的覆盖变化。但 Codecov 的合并算法和 Jacoco 原生有细微差异,特别是多模块项目,它按文件路径聚合,而 Jacoco 按 class name。如果模块之间有同名包,Codecov 会显示重复,Jacoco 不会。


SonarQube(sonarqube.org,社区版免费)的覆盖集成更严格。它要求 XML 报告格式,且 path 属性必须是 Sonar 能解析的源码路径。多模块 Android 项目常见的问题是:Jacoco 报告里的 sourcefilename 是相对路径,但 Sonar 扫描时从每个模块的根目录解析,跨模块引用会报 File not found。解决方式是在根项目生成统一报告,用 rootProject.file(...) 确保路径绝对化,或者配置 Sonar 的 sonar.coverage.jacoco.xmlReportPaths 指向每个模块的报告再让 Sonar 合并。


SonarQube 的覆盖门禁规则有个坑:Coverage on New CodeOverall Coverage 是两个独立指标。团队容易只配 overall,结果老代码的 60% 覆盖把新代码的 40% 拉平均到 55%,过了门禁但新功能没测。建议新项目强制 Coverage on New Code >= 80%,老项目逐步收紧。


GitLab CI 内置的 Coverage 正则提取,对 Jacoco 的 XML 报告不友好,它只解析控制台输出。需要额外配一个任务把 XML 转成百分比打印:


- ./gradlew mergedJacocoReport
- cat build/reports/jacoco/mergedJacocoReport/index.html | grep -oP 'Total[^%]+%'

这个正则很脆弱,HTML 结构一变就失效。更稳的做法是用 xmllint 或 small Python 脚本解析 XML。


Offline Instrumentation 与 R8 的博弈


前面提到 R8 混淆会破坏覆盖率。如果一定要测 release 构建(比如性能优化后的代码路径),需要用 Jacoco 的 offline instrumentation 模式。这个模式下,Jacoco 在编译期直接修改 class 文件插入探针,而不是运行时通过 Java Agent。探针数据通过 org.jacoco.agent.rt.IAgent 接口在运行时写入。


配置步骤:先执行 jacocoInstrument 任务处理 class 文件,再打包 APK。但 Android 的构建流程不允许直接干预 transformClassesWith... 之后的 class,需要注册自定义 Transform API(AGP 7.0 前)或 Gradle Transform Action(AGP 7.0+ 新 API)。


AGP 7.0 废弃了 Transform API,改用 Artifact.TransformInstrumentation API。Jacoco 的 offline instrumentation 还没有官方适配这个新 API。我参考了 jacoco-android-gradle-plugin(GitHub 上已归档,最后更新 3 年前)的做法,自己写了一个 Gradle Plugin 用 AsmClassVisitorFactory 在 AGP 的 instrumentation 阶段插入 Jacoco 探针。代码量不大,但需要对 ASM 字节码操作有基础,且每次 AGP 升级都要验证兼容性。


更务实的做法可能是:release 构建不测覆盖,只测 debug with R8 disabled。Google 官方也这个建议,因为 R8 的优化行为在不同版本有变化,测 release 覆盖的维护成本太高。


报告解读的实践技巧


Jacoco 的 HTML 报告有三个层级:包、类、方法(源码)。看报告时,我习惯先看包的"Missed Branches"列,找红色数字最大的包钻进去。类的视图里,注意"Cxty"(复杂度)和"Missed Branches"的比值,复杂度 20 missed branches 2 的类,比复杂度 5 missed branches 2 的类风险更高——前者还有很多分支根本没测到。


源码视图是最有价值的。绿色行不代表安全,要看行末的分支覆盖标记。Jacoco 用 Fx 表示分支覆盖,x 是分子分母,比如 F3/6 表示 6 个分支覆盖了 3 个。点击类名旁边的 + 展开,能看到每个方法的分支详情。一个常见盲区是:Kotlin 的 ?: Elvis 运算符,val name = user?.profile?.name ?: "Unknown",这行代码编译后涉及多个 null 判断分支,但源码上只是一行,很容易漏测 profile 为 null 而 user 非 null 的情况。


Jacoco 对 lambda 的显示也有特点。Java 的 lambda 生成 lambda$main$0 这样的合成方法,报告里类视图会列出,但源码视图把 lambda 体显示在外部方法的行内。Kotlin 的 SAM 转换更复杂,内联 lambda 可能完全不显示为独立方法,分支信息直接附在调用行。


我通常要求团队成员:提交 PR 时,附上 Jacoco 报告截图或链接,重点标出黄色和红色行。Code review 时,覆盖率下降不是 block 项,但新增代码有红色行必须解释原因——是"这个分支确实不可测"还是"漏写了测试"。这个讨论本身比数字更有价值。


局限与替代方案


Jacoco 的核心局限是 JVM only。Kotlin Multiplatform 的 common 代码、iOS 目标、JavaScript 目标,Jacoco 无法覆盖。Kover(JetBrains 官方,GitHub Kotlin/kotlinx-kover)是 Kotlin 生态的新选择,它基于 IntelliJ 的覆盖率引擎,支持 KMP 全平台,且对 Kotlin 语法结构(inline、coroutines、when)的映射更准确。


Kover 0.7+ 版本已经稳定,Gradle Plugin 配置比 Jacoco 简洁:


plugins {
    id("org.jetbrains.kotlinx.kover") version "0.7.5"
}

但 Kover 也有问题:它的 XML 报告格式和 Jacoco 不兼容,SonarQube 等工具需要适配。JetBrains 提供了 Sonar 插件,但社区版支持滞后。Kover 的 HTML 报告目前不如 Jacoco 详细,缺少方法级别的分支展开。我现在的做法是:纯 Android/JVM 项目继续用 Jacoco,KMP 项目用 Kover 测 common 代码,Android 目标再用 Jacoco 补仪器化测试覆盖,两套数据分别看。


对于不需要字节码插桩的场景,比如只想知道"哪些代码没测试到"而不是精确计数,可以用 IntelliJ 的 IDE 覆盖率运行。它基于 IDE 自己的分析引擎,对 Kotlin 支持更好,且能直接点击未覆盖行生成测试模板。但 IDE 运行不适合 CI,数据也无法持久化对比。


还有一个边缘方案:PIT Mutation Testing(pitest.org)。它不是覆盖工具,而是评估测试有效性的工具——故意修改源码(如把 > 改成 >=),看测试是否失败。如果覆盖率 90% 但 mutation score 只有 30%,说明测试虽然"执行"了代码,但没有有效断言。PIT 运行很慢,大型 Android 项目可能跑几小时,适合核心模块定期跑,不作为每次 CI 门禁。


最后的一点经验


Jacoco 配置这件事,我花了大概三年才算真正摸透。不是因为文档少,而是因为 Android 构建链太长,Jacoco 插在一个中间环节,前后都有工具可能破坏它的假设。每次 AGP 大版本升级,Jacoco 相关配置都是最费时间的部分之一。


我的建议是:把 Jacoco 配置当成基础设施维护,不要复制粘贴网上三年前的博客代码。直接去 Jacoco 的 GitHub release note 看版本变更,去 AGP 的 release note 搜 "jacoco" 或 "coverage" 关键字。Android Gradle Plugin 8.2 开始,单元测试的覆盖率生成改用了 Gradle 的 built-in Jacoco plugin 集成,旧配置会报 deprecation warning,这个变化很多中文资料还没覆盖到。


覆盖率 80% 还是 90% 不是目标,目标是让报告上的红色和黄色区域,对应到真实的风险点。Jacoco 是个工具,工具不会骗人,但人会骗自己——用绿色数字自我安慰,或者为了刷覆盖率写无意义的测试。好的实践是:定期(比如每季度)抽一个覆盖率高的模块,人工 review 它的黄色行,看是不是真的测到位了。这个习惯比任何自动化门禁都有效。

IntelliJ IDEA 社区版开发 Android 的可行性 2026-06-02
Biometric 指纹认证的不同安全等级,怎么选 2026-06-03

评论区