多模块项目 Jacoco 配置指南

在多模块项目中配置 Jacoco 需要创建一个聚合报告来合并所有模块的覆盖率数据。以下是详细的配置说明(使用 Gradle Kotlin DSL):

根项目配置 (根目录下 build.gradle.kts)

plugins {
    jacoco
}

// 配置所有子模块的 Jacoco
subprojects {
    apply(plugin = "jacoco")
    
    // 配置 Jacoco 版本
    jacoco {
        toolVersion = "0.8.12"
    }
    
    // 创建任务运行测试并生成覆盖率数据
    tasks.withType<Test> {
        configure<JacocoTaskExtension> {
            isIncludeNoLocationClasses = true
            excludes = listOf("jdk.internal.*")
        }
        finalizedBy("jacocoTestReport") // 确保测试后生成报告
    }
    
    // 每个模块自己的报告任务
    tasks.register<JacocoReport>("jacocoTestReport") {
        group = "verification"
        description = "Generates Jacoco coverage report for ${project.name}"
        
        // 收集覆盖率数据
        executionData.from(fileTree(project.buildDir) {
            include("**/*.exec", "**/*.ec")
        })
        
        // 配置源文件和类文件
        sourceDirectories.from(files("$projectDir/src/main/java"))
        classDirectories.from(files(fileTree("$buildDir/classes") {
            exclude(
                "**/R.class",
                "**/R$*.class",
                "**/BuildConfig.*",
                "**/Manifest*.*",
                "**/*Test.*",
                "**/generated/**"
            )
        }))
        
        reports {
            xml.required.set(true)
            html.required.set(true)
            csv.required.set(false)
        }
    }
}

// 创建聚合报告任务
tasks.register<JacocoReport>("jacocoAggregateReport") {
    group = "verification"
    description = "Generates aggregate Jacoco coverage report"
    
    // 依赖所有子模块的报告任务
    val projects = subprojects.filter { 
        // 排除不需要覆盖率的模块
        it.path != ":app" && !it.name.contains("test") 
    }
    dependsOn(projects.map { ":${it.name}:jacocoTestReport" })
    
    // 收集所有子模块的执行数据
    executionData.from(projects.map {
        fileTree(it.buildDir) {
            include("jacoco/*.exec", "outputs/code-coverage/*.ec")
        }
    })
    
    // 设置源文件和类文件
    sourceDirectories.from(
        files(projects.map { "$it.projectDir/src/main/java" })
    )
    
    classDirectories.from(
        files(projects.map { 
            fileTree("${it.buildDir}/classes") {
                exclude(
                    "**/R.class",
                    "**/R$*.class",
                    "**/BuildConfig.*",
                    "**/Manifest*.*",
                    "**/*Test.*",
                    "**/generated/**"
                )
            }
        })
    )
    
    reports {
        xml.required.set(true)
        html.required.set(true)
        csv.required.set(false)
        
        // 设置报告输出目录
        html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/html"))
        xml.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/jacocoAggregateReport.xml"))
    }
    
    doFirst {
        executionData.forEach { println("Including execution data: $it") }
    }
}

模块级配置 (每个模块的 build.gradle.kts)

对于每个子模块,只需要基本的 Jacoco 配置(根项目已处理大部分):

// 在子模块的 build.gradle.kts 中

// 确保 Jacoco 配置
jacoco {
    toolVersion = "0.8.12"
}

android {
    // 确保测试覆盖率已启用
    buildTypes {
        getByName("debug") {
            enableUnitTestCoverage = true
            enableAndroidTestCoverage = true
        }
    }
}

tasks.withType<Test> {
    configure<JacocoTaskExtension> {
        isIncludeNoLocationClasses = true
        excludes = listOf("jdk.internal.*")
    }
}

dependencies {
    // 为 Android 测试添加覆盖率支持
    androidTestImplementation("androidx.test:runner:1.5.0")
    androidTestImplementation("androidx.test:rules:1.5.0")
}

特殊情况的处理技巧

1. 排除特定模块

subprojects {
    // 排除不需要覆盖率的模块
    if (project.name == "no-coverage-module") {
        tasks.matching { it.name.contains("jacoco") }.configureEach {
            enabled = false
        }
        return@subprojects
    }
    // ...
}

2. 处理 Android Build Variants

tasks.register<JacocoReport>("jacocoTestReport") {
    // 针对不同构建变体
    val variant = "debug" // 或其他变体
    
    // 使用特定变体的类目录
    classDirectories.setFrom(fileTree("$buildDir/intermediates/javac/$variant/classes"))
    
    // 收集对应变体的执行数据
    executionData.setFrom(fileTree(buildDir) {
        include("**/jacoco/test${variant.capitalize()}UnitTest.exec")
    })
}

3. 合并单元测试和仪器化测试覆盖率

executionData.from(
    fileTree(buildDir) {
        include(
            "jacoco/testDebugUnitTest.exec", // 单元测试
            "outputs/code_coverage/*.ec"     // 仪器化测试
        )
    }
)

4. 处理多版本兼容性

// 兼容旧 AGP 版本
val classesPath = if (project.hasProperty("android")) {
    // Android 模块路径
    "$buildDir/intermediates/javac/debug/classes"
} else {
    // 纯 Java/Kotlin 模块路径
    "$buildDir/classes/kotlin/main"
}

classDirectories.setFrom(files(fileTree(classesPath) {
    // 排除规则
}))

运行聚合报告

# 生成所有模块的覆盖率数据
./gradlew testDebugUnitTest connectedDebugAndroidTest

# 生成聚合报告
./gradlew jacocoAggregateReport

# 打开 HTML 报告
open app/build/reports/jacoco/html/index.html

常见问题解决

1. 报告显示 "0% 覆盖率"

​解决步骤:​​

  1. 检查是否使用了正确的构建变体(如 debug)
  2. 验证每个模块的 .exec 文件是否存在:
    find . -name "*.exec"
  3. 确保 executionData 路径正确

2. "Execution data path contains no files"

​解决方案:​​ 确保所有子模块都正确生成了报告

# 手动执行所有模块的测试
./gradlew testDebugUnitTest --continue

3. 重复计入某些类

​解决方法:​​ 增强排除规则

classDirectories.from(files(projects.map { 
    fileTree("${it.buildDir}/classes") {
        exclude(
            // 添加具体排除模式
            "**/di/**", 
            "**/binding/**",
            "**/*_Factory.*"
        )
    }
}))

最佳实践建议

  1. ​使用模块化排除​

    // 在根项目配置中
    tasks.named("jacocoAggregateReport") {
        classDirectories.from(files(
            projects.filterNot { it.name == "data" }.map { ... }
        ))
    }
  2. ​集成持续集成系统​

    # GitHub Actions 示例
    - name: Generate coverage report
      run: ./gradlew jacocoAggregateReport
      
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        files: build/reports/jacoco/jacocoAggregateReport.xml
  3. ​增量覆盖率统计​

    jacocoTestCoverageVerification {
        violationRules {
            rule {
                limit {
                    minimum = 0.8
                    counter = 'LINE'
                    value = 'COVEREDRATIO'
                }
            }
        }
    }
  4. ​生成模块化报告​

    // 为每个模块生成独立的 HTML 报告
    tasks.withType<JacocoReport>().configureEach {
        reports {
            html.outputLocation.set(
                layout.buildDirectory.dir("reports/jacoco/${project.name}")
            )
        }
    }

这样配置后,您将获得:

  1. 每个模块独立的覆盖率报告
  2. 整个项目的聚合覆盖率报告
  3. 可定制的排除规则
  4. 支持不同构建变体的配置
  5. CI/CD 集成能力

更多推荐