diff --git a/README.md b/README.md index 1f07ccd..af7b3dd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/demo-project/app/build.gradle.kts b/demo-project/app/build.gradle.kts index 8329d41..7029d70 100644 --- a/demo-project/app/build.gradle.kts +++ b/demo-project/app/build.gradle.kts @@ -21,7 +21,8 @@ android { buildTypes { debug { - enableUnitTestCoverage = true + // FIXME UI tests requires an emulator on CI + enableAndroidTestCoverage = true } release { aggregateTestCoverage = false @@ -39,7 +40,7 @@ android { } create("prod") { dimension = "environment" - //aggregateTestCoverage.set(false) + aggregateTestCoverage.set(false) } } @@ -71,4 +72,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.robolectric) + + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.espresso) } diff --git a/demo-project/ui-tests/src/main/kotlin/com/example/myapplication/ExampleInstrumentedTest.kt b/demo-project/app/src/androidTest/kotlin/com/example/myapplication/SomeSingletonStuffUITest.kt similarity index 86% rename from demo-project/ui-tests/src/main/kotlin/com/example/myapplication/ExampleInstrumentedTest.kt rename to demo-project/app/src/androidTest/kotlin/com/example/myapplication/SomeSingletonStuffUITest.kt index 0d17fa1..49c16b5 100644 --- a/demo-project/ui-tests/src/main/kotlin/com/example/myapplication/ExampleInstrumentedTest.kt +++ b/demo-project/app/src/androidTest/kotlin/com/example/myapplication/SomeSingletonStuffUITest.kt @@ -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 } } + diff --git a/demo-project/app/src/main/java/com/example/myapplication/SomeSingleton.kt b/demo-project/app/src/main/java/com/example/myapplication/SomeSingleton.kt new file mode 100644 index 0000000..c22e099 --- /dev/null +++ b/demo-project/app/src/main/java/com/example/myapplication/SomeSingleton.kt @@ -0,0 +1,13 @@ +package com.example.myapplication + +object SomeSingleton { + + fun doStuff() { + println("stuff") + } + + fun doMoreStuff() { + println("more stuff") + } + +} diff --git a/demo-project/build.gradle.kts b/demo-project/build.gradle.kts index 04be850..1e4b820 100644 --- a/demo-project/build.gradle.kts +++ b/demo-project/build.gradle.kts @@ -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") @@ -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>("android") + + val pixel2 by android.testOptions.managedDevices.devices.creating(ManagedVirtualDevice::class) { + device = "Pixel 6" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + } +} diff --git a/demo-project/ui-tests/build.gradle.kts b/demo-project/ui-tests/build.gradle.kts index 0f290e2..d7b9fba 100644 --- a/demo-project/ui-tests/build.gradle.kts +++ b/demo-project/ui-tests/build.gradle.kts @@ -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) } diff --git a/demo-project/ui-tests/src/main/kotlin/com/example/myapplication/SomeSingletonMoreStuffUITest.kt b/demo-project/ui-tests/src/main/kotlin/com/example/myapplication/SomeSingletonMoreStuffUITest.kt new file mode 100644 index 0000000..3089f9b --- /dev/null +++ b/demo-project/ui-tests/src/main/kotlin/com/example/myapplication/SomeSingletonMoreStuffUITest.kt @@ -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 + } +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2775919..ea4c674 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AGPInternalAPIAccessor.kt b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AGPInternalAPIAccessor.kt new file mode 100644 index 0000000..13241f4 --- /dev/null +++ b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AGPInternalAPIAccessor.kt @@ -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 Artifacts.get( + type: Artifact.Single +): Provider = 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() && + it.parameters[1].type == typeOf() + } + ?.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") diff --git a/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestCoverageAggregationPlugin.kt b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestCoverageAggregationPlugin.kt index 3d2dcab..df2f204 100644 --- a/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestCoverageAggregationPlugin.kt +++ b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestCoverageAggregationPlugin.kt @@ -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 @@ -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 { @@ -43,16 +45,41 @@ abstract class AndroidTestCoverageAggregationPlugin : Plugin { // 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)) + } + } } } @@ -72,22 +99,15 @@ abstract class AndroidTestCoverageAggregationPlugin : Plugin { objects.named(VerificationType.JACOCO_RESULTS) ) } - afterEvaluate { - jacocoVariants.all variant@{ - val execData = unitTestTaskOf(this@variant)!! - .map { it.the().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) } } @@ -132,7 +152,7 @@ abstract class AndroidTestCoverageAggregationPlugin : Plugin { ) } - jacocoVariants.all task@{ + aggregatedVariants.all task@{ artifacts .forScope(ScopedArtifacts.Scope.PROJECT) .use(allVariantsClassesForCoverageReport) diff --git a/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestResultsAggregationPlugin.kt b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestResultsAggregationPlugin.kt index 60401bd..63c314e 100644 --- a/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestResultsAggregationPlugin.kt +++ b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/AndroidTestResultsAggregationPlugin.kt @@ -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 @@ -35,12 +36,14 @@ abstract class AndroidTestResultsAggregationPlugin : Plugin { } 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 }) } } } diff --git a/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/InternalDSL.kt b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/InternalDSL.kt index bd30d71..daf1fdb 100644 --- a/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/InternalDSL.kt +++ b/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/InternalDSL.kt @@ -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 @@ -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 @@ -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("test${it.name.capitalized()}") } +internal fun Project.unitTestTaskOf(variant: UnitTest) = provider { + tasks.getByName("test${variant.name.capitalized()}") +} + +internal fun Project.androidTestTaskOf(variant: Type) where Type : GeneratesTestApk, Type: ComponentIdentity = provider { + tasks.getByName("create${variant.name.capitalized()}CoverageReport") +}