How to setup Jacoco for Android project with Java, Kotlin and multiple flavours

Jacoco is a widely used library to measure test code-coverage in JVM-based projects. Setting it up for Android applications has a few quirks and having multiple flavours, using Kotlin and writing (some) tests in Robolectric makes it even tricker. There are already great tutorials in how to set it up, like THIS and THIS one. In this post however I’ll not only give you a ready solution, but share all details how I got to it – this way you’ll be able to adapt it in the best way for your project.

1. Covering unit tests only

Ideally you don’t want to bloat your build.gradle file with random third-party configuration code. To keep things clean – all Jacoco logic will live in it’s own separate file – jacoco.gradle. This file could live next to your main build.gradle file or to keep things even cleaner – I moved it in a separate directory called buildscripts. The project structure now looks like this:


Let’s start implementing the jacoco.gradle file:

apply plugin:  'jacoco'

jacoco {
    toolVersion = "0.8.1"
    // Custom reports directory can be specfied like this:
    // reportsDir = file("$buildDir/customJacocoReportDir")
}

tasks.withType(Test) {
    jacoco.includeNoLocationClasses = true
}

First thing is to make use of Gradle’s Jacoco plugin (line 1). We can then specify two configuration params (the block on line 3): the version of Jacoco (0.8.1 at the time of writing) and optionally where reports will be generated. The second param is commented out, so the default directory – app/build/reports/jacoco/ will be used.

The block on line 9 is the way to correctly set the includeNoLocationClasses property in the latest versions of Jacoco. You’d want to do this if you have Robolectric tests in your suite. Please note that enabling this property was previously done via Android Plugin for Gradle DSL, but this way no longer works!

2. Setting up a Jacoco task

Projects that use Kotlin or have multiple Android flavours need to create a custom Gradle task to generate coverage reports. In this task we’ll tweak a few Jacoco parameters, as otherwise the coverage report won’t be accurate enough (e.g. Kotlin classes will not be included in it). Here’s a snippet to create such task:

project.afterEvaluate {

    android.applicationVariants.all { variant ->
        def variantName = variant.name
        def testTaskName = "test${variantName.capitalize()}UnitTest"

        tasks.create(name: "${testTaskName}Coverage", type: JacocoReport, dependsOn: "$testTaskName") {
            // task implementation here ...
        }
    }
}

Since our project has multiple flavours, we’ll need to create a separate task for each flavour. We iterate over all generated Android variants (line 3). For each one we construct the name of the unit test running task (line 5), which has the format test<YourVariantName>UnitTest. Finally we declare our custom task on line 7. Few things to note here:

  • The name of our task will be test<YourVariantName>UnitTestCoverage. You can pick anything you want here, but as such tasks are created for each variant, it’s a good idea to include the ${variantName} or ${testTaskName} in here.
  • The type of our task is a JacocoReport one, so we can tweak all properties of it (check them in the task documentation).
  • By specifying that our task dependsOn: “$testTaskName”, we guarantee it will always be executed after the unit test are run.
3. Jacoco task implementation

Let’s get to the trickiest bit – implementing the actual task. My solution looks like this:

tasks.create(name: "${testTaskName}Coverage", type: JacocoReport, dependsOn: "$testTaskName") {
	group = "Reporting"
	description = "Generate Jacoco coverage reports for the ${variantName.capitalize()} build."

	reports {
	    html.enabled = true
	    xml.enabled = true
	}

	def excludes = [
	        '**/R.class',
	        '**/R$*.class',
	        '**/BuildConfig.*',
	        '**/Manifest*.*',
	        '**/*Test*.*',
	        'android/**/*.*'
	]
	def javaClasses = fileTree(dir: variant.javaCompiler.destinationDir, excludes: excludes)
	def kotlinClasses = fileTree(dir: "${buildDir}/tmp/kotlin-classes/${variantName}", excludes: excludes)
	classDirectories = files([javaClasses, kotlinClasses])

	sourceDirectories = files([
	        "$project.projectDir/src/main/java",
	        "$project.projectDir/src/${variantName}/java",
	        "$project.projectDir/src/main/kotlin",
	        "$project.projectDir/src/${variantName}/kotlin"
	])

	executionData = files("${project.buildDir}/jacoco/${testTaskName}.exec")
}

A good practice is to always give a group and description when creating custom Gradle tasks (lines 2 and 3). This way they’ll be nicely listed in the output of ./gradlew tasks command for your project:

The next bit is configuring what types of reports we need generated (line 5). The HTML one is more user-friendly and it’s used when inspecting the coverage locally. The XML one is used by the Jenkins Jacoco plugin.

Next we define a list of classes we want to exclude from our coverage reports (line 10). It makes sense to exclude auto-generated code and code that you don’t have control over (e.g. third-party code, etc). If you use Kotlin in your project check out THIS extra read on how to exclude Kotlin-generated code from your reports.

Line 18 defines the path to the compiled java classes. Please note we use the variant.javaCompiler.destinationDir variable that’s provided by Gradle. We’re excluding the classes we want to be ignored from the report using the excludes variable we defined above. Unfortunately the path to the compiled Kotlin classes isn’t provided to us yet, so we need to build it ourselves. At the time of writing this article (Gradle 4.7, Kotlin 1.2.41, Jacoco 0.8.1) it has the format as shown on line 19. I hope Gradle will soon provide a property similar to the Java one, so we won’t need to do this manually.
On line 20 we just set the JacocoReport.classDirectories for our task – e.g. the classes to generate a report for.

Line 22 sets the JacocoReport.sourceDirectories property of our task, where we specify where the source code for the classes above is located. Please note that for multi-flavour projects you can have multiple Java and/or Kotlin source directories. In our example we have two for each language:

  • src/main/java
  • src/<variantName>/java
  • src/main/kotlin
  • src/<variantName>/kotlin

Just list all directories that contain any source code for the specific flavour.

Last thing – on line 29 we set the JacocoReport.executionData property, which links to the .exec file created by Gradle’s Jacoco plugin. This file contains metadata needed to generated the report. Please note the path of the file.

4. Generating Jacoco reports

With the setup above we’re almost ready to generate coverage report for all unit tests (JUnit, Robolectric) for each flavour of the app. The report will correctly cover both Java and Kotlin code.

The last tiny step is to include the file jacoco.gradle as part of your app’s build.gradle file:

... 
apply plugin: 'com.android.application'
apply from: 'buildscripts/jacoco.gradle'
...

And that’s it! You can now generate a report by executing the task we created above:

./gradlew testFreeDebugUnitTestCoverage

Since we didn’t specify an output directory the report is generated in: app/build/reports/jacoco/testFreeDebugUnitTestCoverage.

5. Generating coverage reports for UI tests

In some cases you might want to generate a coverage report for your Instrumentation tests – e.g. if you have a lot of instrumented unit tests (e.g. tests for Activities, Fragments, etc) or even full-blown BDD-style behavioural tests.

To generate such report you can make use of a property available in the Android Gradle Plugin DSL – testCoverageEnabled. Add it in your build.gradle file:

android {
	buildTypes {
        debug {
            ...
	        testCoverageEnabled true
        }
}

Adding this property alone (without any of the work we did above) will automatically add a new Gradle reporting task to the project: createFreeDebugCoverageReport. Executing this task will generate a simple report that includes only your androidTests. By default the report is located in: app/build/reports/coverage/free/debug/.

6. Putting it all together

If you want to generate a single report that covers both unit and UI tests, there’s just a few steps required. If you haven’t already, add the testCoverageEnabled property from step 5. Then we should apply a few changes to the custom task created in steps 1-4:

...
def variantName = variant.name
def testTaskName = "test${variantName.capitalize()}UnitTest"
def uiTestCoverageTaskName = "test${variantName.capitalize()}CoverageReport"

tasks.create(name: "${testTaskName}Coverage", type: JacocoReport, dependsOn: "$testTaskName", "$uiTestCoverageTaskName") {
	...

	executionData = files([
		"${project.buildDir}/jacoco/${testTaskName}.exec",
		"outputs/code-coverage/connected/*coverage.ec"
	]) 
}

Notice how we added another dependency to our custom task – the $uiTestCoverageTaskName task (line 6), which is based on the auto-generated task mentioned in step 5. This dependency generates an .ec coverage report after our androidTests are run. The next change is to include this newly generated file (“outputs/code-coverage/connected/*coverage.ec”) to the executionData configuration of our task (line 11). Please note the wildcard there: *coverage.ec. We use a wildcard, because the name of the file has the name of the actual device the UI tests are run on.

That’s it – running our custom task test<YourFlavourName>UnitTestCoverage will now run both unit and instrumentation tests and will generate report based on both.

Hope you see good numbers when you generate your coverage reports!

3 Comments

  1. Great tutorial!

    Reply
  2. This task is failed with exception:

    java.lang.NoClassDefFoundError: jdk/internal/reflect/GeneratedSerializationConstructorAccessor1

    Reply
    • Which task exactly? For me everything works with Gradle 4.7, JVM: 1.8.0_121 (Oracle Corporation 25.121-b13) on a MacBook Pro.

      Reply

Submit a Comment

Your email address will not be published. Required fields are marked *