Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added androidTest coverage support #16

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ now promoted to dedicated Gradle plugins:
[io.github.gmazzo.test.aggregation.coverage](https://plugins.gradle.org/plugin/io.github.gmazzo.test.aggregation.coverage) and
[io.github.gmazzo.test.aggregation.results](https://plugins.gradle.org/plugin/io.github.gmazzo.test.aggregation.results)


## `UnitTest` and `AndroidTest` code coverage support
By default, the plugin will do `buildTypes["debug]".enableUnitTestCoverage = true` when applied, allowing coverage to be collected.

Coverage on `AndroidTest` variant is also support and will be automatically collected, if `buildType["debug"].enableAndroidTestCoverage = true` is also set (it applies for any `BuildType`)

## Filtering content
The plugins will automatically aggregate `android` modules and `java` modules that also apply `jacoco` plugin on the
`jacocoAggregation` and the `testReportAggregation` configurations.
Expand Down
8 changes: 6 additions & 2 deletions demo-project/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ android {

buildTypes {
debug {
enableUnitTestCoverage = true
// FIXME UI tests requires an emulator on CI
enableAndroidTestCoverage = true
}
release {
aggregateTestCoverage = false
Expand All @@ -39,7 +40,7 @@ android {
}
create("prod") {
dimension = "environment"
//aggregateTestCoverage.set(false)
aggregateTestCoverage.set(false)
}
}

Expand Down Expand Up @@ -71,4 +72,7 @@ dependencies {

testImplementation(libs.junit)
testImplementation(libs.robolectric)

androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.espresso)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import org.junit.runner.RunWith
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
class SomeSingletonStuffUITest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.app.test", appContext.packageName)

SomeSingleton.doStuff() // to mark coverage of UI tests
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.myapplication

object SomeSingleton {

fun doStuff() {
println("stuff")
}

fun doMoreStuff() {
println("more stuff")
}

}
16 changes: 16 additions & 0 deletions demo-project/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ManagedVirtualDevice

plugins {
base
id("io.github.gmazzo.test.aggregation.results")
Expand Down Expand Up @@ -31,3 +34,16 @@ tasks.jacocoAggregatedCoverageVerification {
tasks.check {
dependsOn(tasks.jacocoAggregatedCoverageVerification)
}

// setups emulator for all android projects
allprojects {
plugins.withId("com.android.base") {
val android = extensions.getByName<CommonExtension<*, *, *, *, *>>("android")

val pixel2 by android.testOptions.managedDevices.devices.creating(ManagedVirtualDevice::class) {
device = "Pixel 6"
apiLevel = 30
systemImageSource = "aosp-atd"
}
}
}
10 changes: 5 additions & 5 deletions demo-project/ui-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,18 @@ android {
}
}

val pixel2 by android.testOptions.managedDevices.devices.creating(ManagedVirtualDevice::class) {
device = "Pixel 6"
apiLevel = 30
systemImageSource = "aosp-atd"
androidComponents.finalizeDsl {
// added this here, because the `baselineprofile` plugin resets it back to false for some reason
android.buildTypes["nonMinifiedRelease"].enableAndroidTestCoverage = true
}

baselineProfile {
useConnectedDevices = false
managedDevices += pixel2.name
managedDevices += "pixel2"
}

dependencies {
implementation(projects.demoProject.app)
implementation(libs.androidx.test.junit)
implementation(libs.androidx.test.espresso)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.myapplication

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class SomeSingletonMoreStuffUITest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.app", appContext.packageName)

SomeSingleton.doMoreStuff() // to mark coverage of UI tests
}
}

2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[versions]
kotlin = "1.9.20"
agp = "8.1.2"
agp = "8.3.0-alpha13"
android-minSDK = "21"
android-compileSDK = "34"
androidx-lifecycle = "2.6.2"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
@file:Suppress("PrivateAPI", "ObjectPropertyName")

/**
* Helper to access AGP internal API as the required wiring to get coverage data is not public
*/

package io.github.gmazzo.android.test.aggregation

import com.android.build.api.artifact.Artifact
import com.android.build.api.artifact.Artifacts
import com.android.build.api.artifact.impl.ArtifactsImpl
import com.android.build.api.artifact.impl.InternalScopedArtifact
import com.android.build.api.artifact.impl.ScopedArtifactsImpl
import com.android.build.api.component.analytics.AnalyticsEnabledArtifacts
import com.android.build.api.component.analytics.AnalyticsEnabledScopedArtifacts
import com.android.build.api.variant.ScopedArtifacts
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.FileSystemLocation
import org.gradle.api.provider.Provider
import kotlin.reflect.full.functions
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible
import kotlin.reflect.typeOf

internal fun <FILE_TYPE : FileSystemLocation> Artifacts.get(
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a blocker to me, I don't want to release code doing all this dark magic to access the artifacts

type: Artifact.Single<FILE_TYPE>
): Provider<FILE_TYPE> = when (this) {
is ArtifactsImpl -> get(type)
is AnalyticsEnabledArtifacts -> delegate.get(type)
else -> error("Unknown `Artifacts` type: $this")
}

internal fun ScopedArtifacts.get(type: InternalScopedArtifact): ConfigurableFileCollection =
when (this) {
is ScopedArtifactsImpl -> _getScopedArtifactsContainer(type).finalScopedContent
is AnalyticsEnabledScopedArtifacts -> _delegate.get(type)
else -> error("Unknown `ScopedArtifacts` type: $this")
}

private fun ScopedArtifactsImpl._getScopedArtifactsContainer(type: InternalScopedArtifact) =
ScopedArtifactsImpl::class
.functions
.firstOrNull {
it.name == "getScopedArtifactsContainer" &&
it.parameters.size == 2 &&
it.parameters[0].type == typeOf<ScopedArtifactsImpl>() &&
it.parameters[1].type == typeOf<InternalScopedArtifact>()
}
?.apply { isAccessible = true }
?.call(this, type) as? ScopedArtifactsImpl.ScopedArtifactsContainer
?: error("Can't get `InternalScopedArtifact` type `$type`")

private val AnalyticsEnabledScopedArtifacts._delegate
get() = AnalyticsEnabledScopedArtifacts::class
.memberProperties
.firstOrNull { it.name == "delegate" }
?.apply { isAccessible = true }
?.get(this) as ScopedArtifacts?
?: error("Can't get `AnalyticsEnabledScopedArtifacts`'s delegate")
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
package io.github.gmazzo.android.test.aggregation

import com.android.build.api.artifact.ScopedArtifact
import com.android.build.api.variant.HasAndroidTest
import com.android.build.api.variant.HasUnitTest
import com.android.build.api.variant.ScopedArtifacts
import com.android.build.api.variant.TestVariant
import com.android.build.api.variant.Variant
import com.android.build.gradle.internal.scope.InternalArtifactType
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.type.ArtifactTypeDefinition
Expand All @@ -31,7 +34,6 @@ import org.gradle.kotlin.dsl.namedDomainObjectSet
import org.gradle.kotlin.dsl.property
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.registering
import org.gradle.kotlin.dsl.the
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension

abstract class AndroidTestCoverageAggregationPlugin : Plugin<Project> {
Expand All @@ -43,16 +45,41 @@ abstract class AndroidTestCoverageAggregationPlugin : Plugin<Project> {
// enables jacoco test coverage on `debug` build type by default
android.buildTypes["debug"].enableUnitTestCoverage = true

val jacocoVariants = objects.namedDomainObjectSet(Variant::class)
val aggregatedVariants = objects.namedDomainObjectSet(Variant::class)

androidComponents.onVariants { variant ->
val allVariantsExecDataForCoverageReport by tasks.registering(Sync::class) {
destinationDir = temporaryDir
}

androidComponents.onVariants(androidComponents.selector().all()) { variant ->
val buildType = android.buildTypes[variant.buildType!!]

if ((variant as? HasUnitTest)?.unitTest != null &&
buildType.enableUnitTestCoverage &&
android.shouldAggregate(variant)
) {
jacocoVariants.add(variant)
if (android.shouldAggregate(variant)) {
val unitTest = (variant as? HasUnitTest)?.unitTest
val androidTest = (variant as? HasAndroidTest)?.androidTest

if (buildType.enableUnitTestCoverage && unitTest != null) {
aggregatedVariants.add(variant)
allVariantsExecDataForCoverageReport.configure {
from(unitTest.artifacts.get(InternalArtifactType.UNIT_TEST_CODE_COVERAGE)) {
into("unitTest")
}
}
}
if (buildType.enableAndroidTestCoverage && androidTest != null) {
aggregatedVariants.add(variant)
allVariantsExecDataForCoverageReport.configure {
from(androidTest.artifacts.get(InternalArtifactType.CODE_COVERAGE)) {
into("androidTest")
}
}
}
if (buildType.enableAndroidTestCoverage && variant is TestVariant) {
aggregatedVariants.add(variant)
allVariantsExecDataForCoverageReport.configure {
from(variant.artifacts.get(InternalArtifactType.CODE_COVERAGE))
}
}
}
}

Expand All @@ -72,22 +99,15 @@ abstract class AndroidTestCoverageAggregationPlugin : Plugin<Project> {
objects.named(VerificationType.JACOCO_RESULTS)
)
}
afterEvaluate {
jacocoVariants.all variant@{
val execData = unitTestTaskOf(this@variant)!!
.map { it.the<JacocoTaskExtension>().destinationFile!! }

outgoing.artifact(execData) {
type = ArtifactTypeDefinition.BINARY_DATA_TYPE
}
}
outgoing.artifact(allVariantsExecDataForCoverageReport.map { it.destinationDir }) {
type = ArtifactTypeDefinition.BINARY_DATA_TYPE
}
}

val allVariantsSourcesForCoverageReport by tasks.registering(Sync::class) {
destinationDir = temporaryDir
duplicatesStrategy = DuplicatesStrategy.INCLUDE // in case of duplicated classes
jacocoVariants.all {
aggregatedVariants.all {
from(sources.java?.all, sources.kotlin?.all)
}
}
Expand Down Expand Up @@ -132,7 +152,7 @@ abstract class AndroidTestCoverageAggregationPlugin : Plugin<Project> {
)
}

jacocoVariants.all task@{
aggregatedVariants.all task@{
artifacts
.forScope(ScopedArtifacts.Scope.PROJECT)
.use(allVariantsClassesForCoverageReport)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.gmazzo.android.test.aggregation

import com.android.build.api.variant.HasAndroidTest
import com.android.build.api.variant.HasUnitTest
import org.gradle.api.Plugin
import org.gradle.api.Project
Expand Down Expand Up @@ -35,12 +36,14 @@ abstract class AndroidTestResultsAggregationPlugin : Plugin<Project> {
}

androidComponents.onVariants { variant ->
if ((variant as? HasUnitTest)?.unitTest != null && android.shouldAggregate(variant)) {
afterEvaluate {
val testTask = unitTestTaskOf(variant)!!
val unitTest = (variant as? HasUnitTest)?.unitTest
val androidTest = (variant as? HasAndroidTest)?.androidTest

testResultsElements.outgoing.artifact(testTask.flatMap { it.binaryResultsDirectory })
}
if (unitTest != null && android.shouldAggregate(variant)) {
testResultsElements.outgoing.artifact(unitTestTaskOf(unitTest).flatMap { it.binaryResultsDirectory })
}
if (androidTest != null && android.shouldAggregate(variant)) {
// TODO testResultsElements.outgoing.artifact(androidTestTaskOf(androidTest).flatMap { it.binaryResultsDirectory })
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package io.github.gmazzo.android.test.aggregation

import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.HasUnitTest
import com.android.build.api.variant.ComponentIdentity
import com.android.build.api.variant.GeneratesTestApk
import com.android.build.api.variant.UnitTest
import com.android.build.api.variant.Variant
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.internal.coverage.JacocoReportTask
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.tasks.testing.AbstractTestTask
Expand All @@ -13,7 +16,6 @@ import org.gradle.kotlin.dsl.create
import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.getByName
import org.gradle.kotlin.dsl.named
import org.gradle.kotlin.dsl.testAggregation
import org.gradle.kotlin.dsl.the

Expand Down Expand Up @@ -60,6 +62,10 @@ internal fun TestAggregationExtension.aggregateProject(project: Project, config:
private fun TestAggregationExtension.Modules.includes(project: Project) =
(includes.get().isEmpty() || project in includes.get()) && project !in excludes.get()

internal fun Project.unitTestTaskOf(variant: Variant) = (variant as? HasUnitTest)
?.unitTest
?.let { tasks.named<AbstractTestTask>("test${it.name.capitalized()}") }
internal fun Project.unitTestTaskOf(variant: UnitTest) = provider {
tasks.getByName<AbstractTestTask>("test${variant.name.capitalized()}")
}

internal fun <Type> Project.androidTestTaskOf(variant: Type) where Type : GeneratesTestApk, Type: ComponentIdentity = provider {
tasks.getByName<JacocoReportTask>("create${variant.name.capitalized()}CoverageReport")
}
Loading