3 minute read

Implementing Test Coverage in a Multi-module Android Project with Sonar and JaCoCo/Kover

Intro

For a long time, setting up test coverage in Android projects has been a quite complex and frustrating process. Developers often found themselves wading through a rabbit hole of conflicting information, scrolling the docs, surfing the net, scouring the forums and blog posts in search of elusive solutions. What worked seamlessly for one dev might fail spectacularly for another, leading to countless hours and days of trial and error. This was the reality during the era of JaCoCo.

However, the landscape has shifted dramatically with the arrival of Kover, a Kotlin-first coverage plugin that simplifies the process and makes reliable test coverage setups more accessible than ever before. Kover addresses many of the pain points that JaCoCo users faced, providing a streamlined and intuitive approach to achieving robust test coverage metrics.

In this article, I’ll guide you through a practical and proven setup for implementing test coverage in a multi-module Android project using both JaCoCo and Kover. While I’ll walk you through the configuration of both tools (and you’ll likely notice a stark difference in how much simpler Kover is), I strongly recommend adopting Kover for its modern features and developer-friendly approach. As a bonus, I’ll show how to upload the results of both to SonarCloud.

So, let’s dive straight into it!

Set up Sonar

In short, Sonar is a continuous inspection tool. From the test coverage perspective, Sonar will be used as a host platform that displays the metrics and updates them as soon as they change.

  • File: sonar.gradle
apply plugin: "org.sonarqube"

ext.sonar = [
    ignoreModules : [
        // add here any module you want to ignore
        "documentation",
        "demo"
    ],
    excludeFilter : [
        // add here any regex you want to ignore
        "**/test/**",
        "**/androidTest/**",
        "**/R.class",
        "**/R2.class",
        "**/R$*.class",
        "**/BuildConfig.*",
        "**/Manifest*.*",
        "**/*Test*.*"
    ]
]

ext.sonar.ignoreModules.each {
    ext.sonar.excludeFilter << "**/${it}/**"
}

sonarqube {
    properties {
        property("sonar.host.url", "https://sonarcloud.io")
        property("sonar.token", "${System.getenv("SONAR_TOKEN")}")
        property("sonar.organization", "YOUR ORGANIZATION NAME")
        property("sonar.projectKey", "YOUR PROJECT KEY")
        property("sonar.projectName", "YOUR PROJECT NAME")
        property("sonar.java.coveragePlugin", "jacoco") // this value stays the same for both jacoco and kover
        property("sonar.sourceEncoding", "UTF-8")
        property("sonar.java.binaries", "${rootDir}/**/build/tmp/kotlin-classes/debug")
        property("sonar.coverage.exclusions", rootProject.ext.sonar.excludeFilter)
    }
}
  • File: build.gradle.kts
apply(from = "${rootDir}/sonar.gradle")

Set up Test Coverage

From here, we branch into two realities: dark and bright. Feel free to pick your path and skip the other.

JaCoCo a.k.a Old-school

  • File: jacoco.gradle
if (!rootProject.ext.sonar.ignoreModules.contains(name)) {
    apply plugin: "jacoco"
    apply plugin: "org.sonarqube"

    def sources = "src/main/kotlin"
    def testTask = "testDebugUnitTest"
    def jacocoResults = "${buildDir}/reports/jacoco/reportDebug.xml"

    if (hasProperty("android")) {
        android {
            buildTypes {
                debug {
                    testCoverageEnabled = true
                    enableUnitTestCoverage = true
                    enableAndroidTestCoverage true
                }
            }
        }
    }

    afterEvaluate {
        tasks.withType(Test).configureEach {
            jacoco.includeNoLocationClasses = true
            jacoco.excludes = [
                "jdk.internal.*",
                "androidx.core.*",
                "com.android.*",
                "android.*"
            ]
        }

        tasks.register("testCoverage", JacocoReport) {
            dependsOn testTask

            reports {
                xml.required.set(true)
                xml.outputLocation.set(file(jacocoResults))
            }

            executionData.setFrom(fileTree(dir: buildDir, includes: [
                "outputs/unit_test_code_coverage/debugUnitTest/${testTask}.exec"
            ]))

            sourceDirectories.setFrom(files([
                sources
            ]))

            classDirectories.setFrom(files([
                fileTree(
                    dir: "${buildDir}/tmp/kotlin-classes/debug",
                    excludes: rootProject.ext.sonar.excludeFilter
                )
            ]))
        }
    }

    sonarqube {
        properties {
            property "sonar.junit.reportPaths", "${buildDir}/test-results/${testTask}"
            property "sonar.coverage.jacoco.xmlReportPaths", jacocoResults
            property "sonar.sources", sources
        }
    }
}
  • File: build.gradle.kts
plugins {
    id("org.sonarqube")
}

apply(from = "${rootDir}/sonar.gradle")

subprojects {
    apply(from = "${rootDir}/jacoco.gradle")
}
  • Project structure
Project Structure. JaCoCo
  • Terminal
./gradlew :first-module:testDebugUnitTest \
          :first-module:testCoverage \
          :second-module:testDebugUnitTest \
          :second-module:testCoverage \
          sonar

This command will run tests for all modules in the Debug build variant, generate an XML report using JaCoCo, and upload both test and code coverage results to SonarCloud.

Kover a.k.a New-school

🚧 Keep in mind that, while Kover worked like a charm for me, it is still in beta at the time of writing this article.

  • File: kover.gradle
if (!rootProject.ext.sonar.ignoreModules.contains(name)) {
    apply plugin: "org.jetbrains.kotlinx.kover"
    apply plugin: "org.sonarqube"

    if (hasProperty("android")) {
        android {
            buildTypes {
                debug {
                    testCoverageEnabled = true
                    enableUnitTestCoverage = true
                    enableAndroidTestCoverage true
                }
            }
        }
    }

    sonarqube {
        properties {
            property "sonar.junit.reportPaths", "${buildDir}/test-results/testDebugUnitTest"
            property "sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/reports/kover/reportDebug.xml" // this key stays the same for both jacoco and kover
            property "sonar.sources", "src/main/kotlin"
        }
    }
}
  • File: build.gradle.kts
plugins {
    id("org.sonarqube")
    id("org.jetbrains.kotlinx.kover")
}

apply(from = "${rootDir}/sonar.gradle")

subprojects {
    apply(from = "${rootDir}/kover.gradle")
}
  • Project structure
Project Structure. Kover
  • Terminal
./gradlew :first-module:koverXmlReportDebug \
          :second-module:koverXmlReportDebug \
          sonar

This command will run tests for all modules in the Debug build variant, generate an XML report using Kover, and upload both test and code coverage results to SonarCloud.

Resources

Updated: