Skip to content

Instantly share code, notes, and snippets.

@tsjensen
Last active March 29, 2024 14:32
Show Gist options
  • Save tsjensen/d8b9ab9e6314ae2f63f4955c44399dad to your computer and use it in GitHub Desktop.
Save tsjensen/d8b9ab9e6314ae2f63f4955c44399dad to your computer and use it in GitHub Desktop.
Gradle: Create a JaCoCo Report aggregating all subprojects

Create an Aggregated JaCoCo Report

The JaCoCo results from all subprojects shall be combined.

Requirements

  • Don't make any assumptions about where files are located in the build folders.
  • Refer to the sources of truth for getting at needed information.
  • Don't make any assumptions about which source sets are being tested. It might be main, but it might not.
  • Handle subprojects that don't JaCoCo.
  • Handle Test tasks that don't have any tests, or don't produce a .exec file for some other reason.
  • Correctly declare inputs and outputs for up-to-date checking.
  • Groovy DSL
  • The code below was written for Gradle 6.1.1.

Code

def getProjectList() {
    // These projects are considered. Replace with a different list as needed.
    subprojects + project
}

task jacocoMerge(type: JacocoMerge) {
    group = LifecycleBasePlugin.VERIFICATION_GROUP
    description = 'Merge the JaCoCo data files from all subprojects into one'
    project.afterEvaluate {  // do it at the end of the config phase to be sure all information is present
        FileCollection execFiles = project.objects.fileCollection()   // an empty FileCollection
        getProjectList().each { Project subproject ->
            if (subproject.pluginManager.hasPlugin('jacoco')) {
                def testTasks = subproject.tasks.withType(Test)
                dependsOn(testTasks)   // ensure that .exec files are actually present

                testTasks.each { Test task ->
                    // The JacocoTaskExtension is the source of truth for the location of the .exec file.
                    JacocoTaskExtension extension = task.getExtensions().findByType(JacocoTaskExtension.class);
                    if (extension != null) {
                        execFiles.from extension.getDestinationFile()
                    }
                }
            }
        }
        executionData = execFiles
    }
    doFirst {
        // .exec files might be missing if a project has no tests. Filter in execution phase.
        executionData = executionData.filter { it.canRead() }
    }
}

def getReportTasks(JacocoReport pRootTask) {
    getProjectList().collect {
        it.tasks.withType(JacocoReport).findAll { it != pRootTask }
    }.flatten()
}

task jacocoRootReport(type: JacocoReport, dependsOn: tasks.jacocoMerge) {
    group = LifecycleBasePlugin.VERIFICATION_GROUP
    description = 'Generates an aggregate report from all subprojects'

    logger.lifecycle 'Using aggregated file: ' + tasks.jacocoMerge.destinationFile
    executionData.from tasks.jacocoMerge.destinationFile

    project.afterEvaluate {
        // The JacocoReport tasks are the source of truth for class files and sources.
        def reportTasks = getReportTasks(tasks.jacocoRootReport)
        classDirectories.from project.files({
            reportTasks.collect {it.classDirectories}.findAll {it != null}
        })
        sourceDirectories.from project.files({
            reportTasks.collect {it.sourceDirectories}.findAll {it != null}
        })
    }
}

Hope this helps someone! Let me know in the comments how the code could be better.

Credits

The above solution combines many great comments and suggestions from these pages:

@NikolayMetchev
Copy link

@dhakehurst
Copy link

dhakehurst commented Jul 28, 2020

Gradle docs has a working version of this:
https://docs.gradle.org/6.5.1/samples/sample_jvm_multi_project_with_code_coverage.html

that does not work if some of your modules do not have tests, unfortunately

@barfuin
Copy link

barfuin commented Jul 30, 2020

Thanks @NikolayMetchev for posting the Gradle example! In fact, I used it to improve my plugin code so that it no longer needs to rely on evaluationDependsOnChildren().

The Gradle example shows how to configure the aggregator task in piecemeal fashion - so whenever a new report task gets added anywhere in the build, the callback is invoked and the aggregator task config is modified.
However, the Gradle example is not complete, as it's missing handling of missing tests, or handling of exclusions, and some other real-life corner cases. Adding support for those made things more complex.

@soberich
Copy link

soberich commented Sep 8, 2020

(I copy my comment from here)


new jvm-ecosystem plugin to create aggregated report
and
Higher level APIs for the Java ecosystem.pdf proposes a way to write all boilerplate to create resolvable configurations

    //NOT like this
    ...
    isVisible = false
    isCanBeResolved = true
    isCanBeConsumed = false
    extendsFrom(configurations.implementation.get())
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION))
        attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named("source-folders"))
    }
    ...

in idiomatic way, which Gradle guys already dogfood

@rle125
Copy link

rle125 commented Aug 4, 2023

The above example works great on Gradle 6.9.4 + Java 8. However, after upgrading to Gradle 7.3.3. + OpenJDK 17, the report is empty. The test.exec and jacocoMerge.exec are created, but still an empty report. I added some logging and both environments seem identical.

Has anyone been able to migrate this example to work with Gradle 7+ and Java 17?

@NikolayMetchev
Copy link

I've switched over to kotlinx Kover. Works great.

@rle125
Copy link

rle125 commented Aug 4, 2023

Upgrading the jacoco to version 0.8.8 resolved the issue:

Release 0.8.8 (2022/04/05)
New Features

JaCoCo now officially supports Java 17 and 18 (GitHub [#1282](https://github.com/jacoco/jacoco/issues/1282), [#1198](https://github.com/jacoco/jacoco/issues/1198)).

@nbauma109
Copy link

This didn't work for me until I did a few changes.
My subprojects have these tasks:

apply plugin: 'jacoco'

jacoco {
    toolVersion = '0.8.11'
}

task javaCodeCoverageReport(type: JacocoReport, dependsOn: test) {
    sourceDirectories = files(['src/main/java'])
    classDirectories = files(['build/classes/main'])
    executionData = files('build/jacoco/test.exec')
    reports {
        xml.enabled = true
        html.enabled = true
    }
}

And the root project has these tasks :

apply plugin: 'jacoco'

dependencies {
	jacocoAnt 'org.jacoco:org.jacoco.ant:0.8.11'
}

task jacocoMerge(type: JacocoMerge) {
    group = LifecycleBasePlugin.VERIFICATION_GROUP
    description = 'Merge the JaCoCo data files from all subprojects into one'
    destinationFile = file("$buildDir/jacoco/jacocoMerge.exec")
    afterEvaluate {  // do it at the end of the config phase to be sure all information is present
        FileCollection execFiles = files()   // an empty FileCollection
        subprojects.each { subproject ->
            if (subproject.pluginManager.hasPlugin('jacoco')) {
                def testTasks = subproject.tasks.withType(Test)
                // ensure that .exec files are actually present
                testTasks.each { task ->
                    // The JacocoTaskExtension is the source of truth for the location of the .exec file.
                    JacocoTaskExtension extension = task.getExtensions().findByType(JacocoTaskExtension.class);
                    if (extension != null) {
                        execFiles.from extension.getDestinationFile()
                    }
                }
            }
        }
        executionData = execFiles
    }
    doFirst {
        // .exec files might be missing if a project has no tests. Filter in execution phase.
        executionData = executionData.filter { it.canRead() }
    }
}

def getReportTasks() {
    subprojects.collect { subproject ->
        subproject.tasks.withType(JacocoReport).findAll { it.name == 'javaCodeCoverageReport' }
    }.flatten()
}


task jacocoRootReport(type: JacocoReport, dependsOn: tasks.jacocoMerge) {
    group = LifecycleBasePlugin.VERIFICATION_GROUP
    description = 'Generates an aggregate report from all subprojects'

    executionData = files("$buildDir/jacoco/jacocoMerge.exec")

    afterEvaluate {

        // The JacocoReport tasks are the source of truth for class files and sources.
        def reportTasks = getReportTasks()
        classDirectories = files({
            reportTasks.collect {it.classDirectories}.findAll {it != null}
        })
        sourceDirectories = files({
            reportTasks.collect {it.sourceDirectories}.findAll {it != null}
        })
    }
    reports {
        xml.enabled = true
        html.enabled = true
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment