import groovy.xml.XmlSlurper import groovy.xml.slurpersupport.NodeChild import java.io.File import java.util.Locale import org.gradle.api.GradleException import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.extra import org.gradle.kotlin.dsl.register import org.gradle.testing.jacoco.tasks.JacocoReport import kotlin.math.roundToInt plugins { id("com.android.library") id("jacoco") } private val limits = mutableMapOf( "instruction" to 0.0, "branch" to 0.0, "line" to 0.0, "complexity" to 0.0, "method" to 0.0, "class" to 0.0 ) extra.set("limits", limits) dependencies { "implementation"("org.jacoco:org.jacoco.core:${Versions.jacoco}") } project.afterEvaluate { val buildTypes = android.buildTypes.map { type -> type.name } var productFlavors = android.productFlavors.map { flavor -> flavor.name } if (productFlavors.isEmpty()) { productFlavors = productFlavors + "" } productFlavors.forEach { flavorName -> buildTypes.forEach { buildTypeName -> val sourceName: String val sourcePath: String if (flavorName.isEmpty()) { sourceName = buildTypeName sourcePath = buildTypeName } else { sourceName = "${flavorName}${buildTypeName.replaceFirstChar(Char::titlecase)}" sourcePath = "${flavorName}/${buildTypeName}" } val testTaskName = "test${sourceName.replaceFirstChar(Char::titlecase)}UnitTest" //println("Task -> $testTaskName") registerCodeCoverageTask( testTaskName = testTaskName, sourceName = sourceName, sourcePath = sourcePath, flavorName = flavorName, buildTypeName = buildTypeName ) } } } val excludedFiles = mutableSetOf( // data binding "android/databinding/**/*.class", "**/android/databinding/*Binding.class", "**/android/databinding/*", "**/androidx/databinding/*", "**/BR.*", // android "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*", "android/**/*.*", // kotlin "**/*MapperImpl*.*", "**/*\$ViewInjector*.*", "**/*\$ViewBinder*.*", "**/BuildConfig.*", "**/*Component*.*", "**/*BR*.*", "**/Manifest*.*", "**/*\$Lambda\$*.*", "**/*Companion*.*", "**/*Module*.*", "**/*Dagger*.*", "**/*Hilt*.*", "**/*MembersInjector*.*", "**/*_MembersInjector.class", "**/*_Factory*.*", "**/*_Provide*Factory*.*", "**/*Extensions*.*", // sealed and data classes "**/*\$Result.*", "**/*\$Result\$*.*", // adapters generated by moshi "**/*JsonAdapter.*" ) fun Project.registerCodeCoverageTask( testTaskName: String, sourceName: String, sourcePath: String, flavorName: String, buildTypeName: String ) { tasks.register("${testTaskName}Coverage") { dependsOn(testTaskName) group = "Reporting" description = "Generate Jacoco coverage reports on the ${sourceName.replaceFirstChar(Char::titlecase)} build." val javaDirectories = fileTree( "${project.buildDir}/intermediates/classes/${sourcePath}" ) { exclude(excludedFiles) } val kotlinDirectories = fileTree( "${project.buildDir}/tmp/kotlin-classes/${sourcePath}" ) { exclude(excludedFiles) } val coverageSrcDirectories = listOf( "src/main/java", "src/main/kotlin", "src/$flavorName/java", "src/$flavorName/kotlin", "src/$buildTypeName/java", "src/$buildTypeName/kotlin" ) classDirectories.setFrom(files(javaDirectories, kotlinDirectories)) additionalClassDirs.setFrom(files(coverageSrcDirectories)) sourceDirectories.setFrom(files(coverageSrcDirectories)) executionData.setFrom( files("${project.buildDir}/jacoco/${testTaskName}.exec") ) reports { xml.required.set(true) html.required.set(true) } doLast { jacocoTestReport("${testTaskName}Coverage") } } } @Suppress("UNCHECKED_CAST") fun Project.jacocoTestReport(testTaskName: String) { val reportsDirectory = jacoco.reportsDirectory.asFile.get() val report = file("$reportsDirectory/${testTaskName}/${testTaskName}.xml") logger.lifecycle("Checking coverage results: $report") val metrics = report.extractTestsCoveredByType() val limits = project.extra["limits"] as Map val failures = metrics.filter { entry -> entry.value < limits[entry.key]!! }.map { entry -> "- ${entry.key} coverage rate is: ${entry.value}%, minimum is ${limits[entry.key]}%" } if (failures.isNotEmpty()) { logger.quiet("------------------ Code Coverage Failed -----------------------") failures.forEach { logger.quiet(it) } logger.quiet("---------------------------------------------------------------") throw GradleException("Code coverage failed") } logger.quiet("------------------ Code Coverage Success -----------------------") metrics.forEach { entry -> logger.quiet("- ${entry.key} coverage rate is: ${entry.value}%") } logger.quiet("---------------------------------------------------------------") } @Suppress("UNCHECKED_CAST") fun File.extractTestsCoveredByType(): Map { val xmlReader = XmlSlurper().apply { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) setFeature("http://apache.org/xml/features/disallow-doctype-decl", false) } val counterNodes: List = xmlReader .parse(this).parent() .children() .filter { (it as NodeChild).name() == "counter" } as List return counterNodes.associate { nodeChild -> val type = nodeChild.attributes()["type"].toString().lowercase(Locale.ENGLISH) val covered = nodeChild.attributes()["covered"].toString().toDouble() val missed = nodeChild.attributes()["missed"].toString().toDouble() val percentage = ((covered / (covered + missed)) * 10000.0).roundToInt() / 100.0 Pair(type, percentage) } }