30、测试覆盖率与CI集成:JaCoCo配置、GitHub Actions集成、测试报告生成

说实话,很多团队写测试就是「为了写而写」。测了半天,到底覆盖了多少代码?没人知道。我以前也踩过这个坑——测试用例写了一堆,结果核心逻辑根本没测到。直到我把覆盖率工具CI流水线串起来,才真正让测试变成了「质量守门员」。

这一章,我们就来聊聊怎么用 JaCoCo 做覆盖率分析,怎么把它集成到 GitHub Actions 里,以及怎么生成一份能看懂的报告。

为什么需要覆盖率?

覆盖率不是万能的,但没有覆盖率是万万不能的。它至少能告诉你三件事:

  • 哪些代码从来没被执行过——可能是死代码,也可能是漏测了
  • 测试的「盲区」在哪里——比如某个 if 分支永远没走到
  • 代码变更后,测试有没有跟上——CI 里一跑就现原形

我个人习惯把覆盖率当作「健康检查」,而不是「KPI」。别为了凑 80% 去写一堆没意义的测试,那还不如不写。

JaCoCo 配置:从 Gradle 开始

JaCoCo 是 Java/Kotlin 生态里最常用的覆盖率工具。在 Compose 项目里配置它,其实很简单。

首先,在 build.gradle.kts(模块级别)里加上插件:

plugins {
    id("jacoco")
}

jacoco {
    toolVersion = "0.8.11"
}

tasks.register<JacocoReport>("jacocoTestReport") {
    dependsOn("testDebugUnitTest")

    reports {
        xml.required.set(true)
        html.required.set(true)
    }

    val fileFilter = listOf(
        "**/R.class",
        "**/R$*.class",
        "**/BuildConfig.*",
        "**/Manifest*.*",
        "**/*Test*.*",
        "**/ComposableSingletons*"
    )

    val debugTree = fileTree("${buildDir}/tmp/kotlin-classes/debug") {
        exclude(fileFilter)
    }

    val mainSrc = "${project.projectDir}/src/main/java"

    sourceDirectories.setFrom(files(mainSrc))
    classDirectories.setFrom(files(debugTree))
    executionData.setFrom(fileTree(buildDir) {
        include("jacoco/testDebugUnitTest.exec")
    })
}

嗯,这里要注意:Compose 的 Composable 函数会被编译器生成一些辅助类,比如 ComposableSingletons,这些一定要过滤掉,否则覆盖率会被「虚高」。

小提示:如果你用了 Ktor 或者 Room,它们的生成代码也建议过滤掉。否则报告里会多出一堆你根本不需要关心的类。

GitHub Actions 集成:让覆盖率自动跑

配置好本地任务后,下一步就是把它塞进 CI 流水线。我个人推荐在 PR 触发时跑覆盖率,而不是每次 push 都跑——节省时间,也减少噪音。

在项目根目录创建 .github/workflows/test.yml

name: Run Tests with Coverage

on:
  pull_request:
    branches: [ main, develop ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Run unit tests with coverage
        run: ./gradlew jacocoTestReport

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: app/build/reports/jacoco/jacocoTestReport/html/

跑完之后,覆盖率报告会以 Artifact 的形式保存。你可以在 GitHub Actions 的页面里直接下载 HTML 报告,打开就能看到每个类的覆盖情况。

注意:如果你用的是 macOS-latestwindows-latest,路径分隔符可能不一样。建议统一用 ubuntu-latest 来跑测试,省心。

生成可读的测试报告

JaCoCo 默认生成的 HTML 报告其实已经不错了。它会用颜色标记:

  • 绿色:已覆盖
  • 红色:未覆盖
  • 黄色:部分覆盖(比如 if-else 只走了其中一个分支)

但如果你想让报告更「高级」一点,可以加上 覆盖率徽章。比如用 jacoco-badge-generator 这个 Action:

- name: Generate coverage badge
  uses: cicirello/jacoco-badge-generator@v2
  with:
    badges-directory: badges
    generate-branches-badge: true

这样你的 README 里就能放一个实时更新的徽章:
![Coverage](badges/jacoco.svg)

我曾经在一个开源项目里这么干过,效果很好。PR 提交者看到徽章变红了,自己就会去补测试——省了我不少 review 时间。

覆盖率阈值:别让 CI 变成摆设

光生成报告还不够,你得让 CI 在覆盖率不达标时直接失败。否则大家看看报告就过去了,没人会真正去改。

build.gradle.kts 里加一个检查任务:

tasks.register<JacocoCoverageVerification>("jacocoCoverageCheck") {
    dependsOn("jacocoTestReport")

    violationRules {
        rule {
            limit {
                minimum = "0.80".toBigDecimal() // 行覆盖率不低于 80%
            }
        }

        rule {
            element = "CLASS"
            excludes = listOf(
                "*.databinding.*",
                "*.di.*",
                "*.hilt.*"
            )
            limit {
                counter = "BRANCH"
                minimum = "0.70".toBigDecimal() // 分支覆盖率不低于 70%
            }
        }
    }
}

然后在 CI 里加上这个任务:

- name: Verify coverage
  run: ./gradlew jacocoCoverageCheck

如果覆盖率低于阈值,CI 就会报红。这时候开发者就必须去补测试或者调整代码。

核心观点:覆盖率阈值不是用来「卡人」的,而是用来「提醒」的。我建议初期设低一点(比如 60%),等团队习惯养成后再逐步提高到 80%。

Compose UI 测试的覆盖率陷阱

这里我要特别提醒一下:Compose UI 测试的覆盖率和单元测试的覆盖率是两回事

JaCoCo 默认只统计 JVM 单元测试 的覆盖率。UI 测试(比如 createComposeRule 那种)跑在 Android 设备上,用的是不同的执行数据文件。如果你想合并两者,需要额外配置:

// 在 build.gradle.kts 里
tasks.register<JacocoReport>("jacocoFullReport") {
    dependsOn("testDebugUnitTest", "connectedDebugAndroidTest")

    executionData.setFrom(
        fileTree(buildDir) {
            include(
                "jacoco/testDebugUnitTest.exec",
                "outputs/code_coverage/debugAndroidTest/connected/*.ec"
            )
        }
    )
    // ... 其余配置同上
}

不过说实话,我一般只关注单元测试的覆盖率。UI 测试更看重「场景覆盖」而不是「代码覆盖」——你想想看,一个 Composable 函数可能只有 10 行代码,但它的交互路径可能有几十条。用行覆盖率去衡量 UI 测试,意义不大。

SVG 流程图:覆盖率与 CI 集成全景

覆盖率与CI集成流程 编写代码 Compose UI + ViewModel 编写测试 单元测试 + UI测试 本地运行 ./gradlew test GitHub Actions PR触发 → 自动运行 JaCoCo 覆盖率分析 生成 .exec + HTML/XML 生成报告 + 覆盖率徽章 HTML报告上传Artifact / 徽章更新至README 覆盖率阈值检查 低于80% → CI失败

常见问题与避坑

最后分享几个我实际踩过的坑:

  • JaCoCo 版本和 AGP 版本不兼容:我曾经因为 JaCoCo 0.8.7 和 AGP 8.0 搭配,导致生成报告时一直报错。后来升到 0.8.11 就好了。建议你直接上最新稳定版。
  • Kotlin 内联函数导致覆盖率虚高:内联函数会在调用处展开,JaCoCo 会认为这些代码被「覆盖」了。但实际上你只测了其中一个调用点。这个目前没有完美解法,只能靠人工 review。
  • CI 里跑 UI 测试的覆盖率很慢:因为要启动模拟器。我建议把 UI 测试的覆盖率单独做一个 nightly 任务,PR 阶段只跑单元测试的覆盖率。
我的习惯:每次发版前,我会手动看一遍 JaCoCo 报告里「红色」最多的几个类。如果发现某个核心类的覆盖率低于 50%,我会要求开发补测试再合入。这个流程坚持了半年,线上 bug 率降了 40% 左右。

好了,这一章的内容就到这里。覆盖率不是终点,而是起点。把它和 CI 集成好,你就能在每次代码变更时自动获得一份「质量体检报告」。剩下的,就是持续改进的事了。

公众号:蓝海资料掘金营,微信deep3321