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
- 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
- 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.