Setting up jacoco for multi-module Android codebase with Gadle kotlin

1.2k Views Asked by At

I am currently setting up Jacoco in an Android codebase that my team built. I don't have much experience in Android but I have set up Jacoco before in a Spring Boot codebase so that I can track the test coverage in Sonarqube. But I am having hard time doing in the Android codebase.

So the directory layout looks like this

MyApp/
├─ app/
│  ├─ build/
│  ├─ src/
│  ├─ build.gradle.kts
├─ buildSrc/
│  ├─ build.gradle.kts
├─ modules/
│  ├─ module1/
│  │  ├─ src/
│  │  ├─ build.gradle.kts
│  ├─ module2/
│  │  ├─ src/
│  │  ├─ build.gradle.kts
├─ build.gradle.kts
├─ gradle.properties
├─ gradlew
├─ settings.gradle.kts

I tried adding jacoco in the MyApp/build.gradle.kts.

plugins {
    id(Dependencies.Plugins.androidApplication) version Dependencies.Versions.androidAppplication apply false
    id(Dependencies.Plugins.androidLibrary) version Dependencies.Versions.androidLibrary apply false
    id(Dependencies.Plugins.hilt) version Dependencies.Versions.hilt apply false
    id(Dependencies.Plugins.openApi) version Dependencies.Versions.openApi
    id(Dependencies.Plugins.kotlinAndroid) version Dependencies.Versions.kotlinAndroid apply false
    id(Dependencies.Plugins.sonarqube) version Dependencies.Versions.sonarqube
    
    id("jacoco")
}

I tried executing bash gradlew test jacocoTestReport but it says

Task 'jacocoTestReport' not found in root project 'MyApp' and its subprojects.

I tried adding the line below in the MyApp/build.gradle.kts, per JaCoco Plugin documentation (https://docs.gradle.org/current/userguide/jacoco_plugin.html)

tasks.test {
    finalizedBy(tasks.jacocoTestReport) // report is always generated after tests run
}
tasks.jacocoTestReport {
    dependsOn(tasks.test) // tests are required to run before generating the report
}

but the output says something like below

Script compilation errors:

  Line 12: tasks.test {
                 ^ Unresolved reference: test

  Line 13:     finalizedBy(tasks.jacocoTestReport)                                              
               ^ Unresolved reference: finalizedBy

I did similar configuration in a Spring Boot project before which ran normal. But here it cannot even detect task jacocoTestReport.

What did I do wrong?

1

There are 1 best solutions below

2
On BEST ANSWER

In a traditional Java or Kotlin JVM project, there is a single test task, accessible via tasks.test.

However, in an Android project, there isn't a single test task. Instead, there are multiple test tasks, one for each build variant. The test tasks are named test<Variant>UnitTest. For example, you might have testDebugUnitTest and testReleaseUnitTest.

When you try to access tasks.test in an Android project, it results in an "Unresolved reference: test" error because there is no task with the name test.

Similarly, finalizedBy(tasks.jacocoTestReport) is a way to specify task dependencies, saying that the jacocoTestReport task should always be run after the test task. However, since there is no test task in an Android project, this results in an "Unresolved reference: finalizedBy" error.


Following "How do I add plugins to subprojects based on what plugins are present?", you might need to create a Jacoco setup in your root build.gradle.kts that automatically applies the correct Jacoco setup for each module that uses the Android plugin.

Using a Gradle DLS script:

import org.gradle.testing.jacoco.tasks.JacocoReport

plugins {
    // your existing plugins...

    id("jacoco")
}

// rest of your configurations...

subprojects { subproject ->
    subproject.plugins.withId(Dependencies.Plugins.androidApplication) {
        setupJacoco(subproject)
    }
    subproject.plugins.withId(Dependencies.Plugins.androidLibrary) {
        setupJacoco(subproject)
    }
}


fun setupJacoco(project: Project) {
    project.tasks.register("jacocoTestReport", JacocoReport::class.java) { task ->
        task.group = "Reporting"
        task.description = "Generate Jacoco coverage reports after running tests."
        task.reports {
            xml.isEnabled = true
            html.isEnabled = true
        }
        task.executionData.setFrom(
            project.fileTree(project.buildDir).apply {
                include("**/jacoco/*.exec")
            }
        )
        task.sourceDirectories.setFrom(
            project.files(
                project.extensions.getByType<SourceSetContainer>().getByName("main").allSource.srcDirs
            )
        )
        task.classDirectories.setFrom(
            project.fileTree(
                project.extensions.getByType<SourceSetContainer>().getByName("main").output.classesDirs
            )
        )
    }
}

That script will check every subproject in the build. If a subproject applies your IDs, like Dependencies.Plugins.androidApplication or Dependencies.Plugins.androidLibrary plugin, it adds Jacoco tasks for that subproject.
For each of these projects, add a jacocoTestReport task which generates the Jacoco reports.

One important thing to note is that the Android Gradle plugin creates a test task for each build variant of the app/library module. For example, if you have debug and release build types, the Android Gradle plugin will create testDebugUnitTest and testReleaseUnitTest tasks.

Meaning, you will need to run the specific test task and then jacocoTestReport like this:

# Do this once, after making changes to your build.gradle.kts 
./gradlew --refresh-dependencies

# Then, each time you want to generate the report
./gradlew module1:testDebugUnitTest module1:jacocoTestReport

That will run the unit tests for module1 and then generate the Jacoco report. Note that if you are using product flavors, you might have more than two types of test tasks (e.g., testFreeDebugUnitTest, testPaidDebugUnitTest, etc.), so do adjust your command accordingly to fit your project configuration.


How to consolidate results into single report?

Consolidating JaCoCo coverage reports from multiple modules into a single report in a multi-module Android project means aggregating the execution data (*.exec files) and source information from all relevant subprojects.

That would require a custom Gradle task in your root build.gradle.kts file that gathers data from all modules and generates a comprehensive report.

In your root build.gradle.kts, define a task that will collect and aggregate coverage data from all subprojects. That task will depend on all individual jacocoTestReport tasks to make sure all coverage data is available before aggregation.

import org.gradle.api.tasks.testing.Test
import org.gradle.testing.jacoco.tasks.JacocoReport

// Define a task that aggregates all Jacoco reports into one
tasks.register("jacocoRootReport", JacocoReport::class.java) { rootReportTask ->
    rootReportTask.group = "Verification"
    rootReportTask.description = "Generates an aggregated report from all subprojects."
    
    // Paths to include in the report
    val sourcePaths = files(subprojects.map { it.projectDir.resolve("src/main/kotlin") })
    val classPaths = files(subprojects.map { it.buildDir.resolve("intermediates/javac/debug/classes") })
    val executionDataPaths = files(subprojects.map { it.buildDir.resolve("jacoco/testDebugUnitTest.exec") })

    // Set the class directories, source directories, and execution data
    rootReportTask.classDirectories.setFrom(classPaths.filter { it.exists() })
    rootReportTask.sourceDirectories.setFrom(sourcePaths.filter { it.exists() })
    rootReportTask.executionData.setFrom(executionDataPaths.filter { it.exists() })

    // Specify report formats (e.g., HTML, XML)
    rootReportTask.reports {
        xml.required.set(true)
        html.required.set(true)
    }

    // Make sure this task runs after all individual jacocoTestReport tasks
    subprojects.forEach { subproject ->
        subproject.tasks.withType(JacocoReport::class.java).configureEach { task ->
            rootReportTask.dependsOn(task)
        }
    }
}

The example paths provided (src/main/kotlin, intermediates/javac/debug/classes, jacoco/testDebugUnitTest.exec) are based on a typical Android project structure: adapt them based on your project's configuration:

  • Use src/main/java if your project primarily uses Java.
  • Adjust the class paths if your build variants differ (e.g., replace debug with release if you are generating reports for release builds).
  • If your modules use different paths for source or class files, make sure these are correctly reflected.

To generate the aggregated report, run the following command:

./gradlew clean jacocoRootReport

That would first cleans previous builds to make sure fresh data is used for the report, then runs the jacocoRootReport task, which aggregates data from all subprojects.