Skip to content
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
4 changes: 4 additions & 0 deletions build-logic/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,9 @@ gradlePlugin {
id = "io.sentry.spring-boot-matrix"
implementationClass = "io.sentry.gradle.SpringBootMatrixPlugin"
}
register("sentryRobolectricTestSplit") {
id = "io.sentry.robolectric-test-split"
implementationClass = "io.sentry.gradle.RobolectricTestSplitPlugin"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package io.sentry.gradle

import com.android.build.api.variant.HostTest
import com.android.build.api.variant.HostTestBuilder
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Action
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.file.FileTreeElement
import org.gradle.api.internal.tasks.testing.filter.DefaultTestFilter
import org.gradle.api.specs.Spec
import org.gradle.api.tasks.testing.Test
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.objectweb.asm.AnnotationVisitor
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.Opcodes
import org.objectweb.asm.Type

/**
* Splits an Android library's unit tests into two tasks, so only the Robolectric tests pay for a
* large heap and run in several forks:
* - `test<Variant>UnitTest`, the AGP task, runs the Robolectric tests. Robolectric loads an
* android-all jar into each test JVM, which needs more heap than the default.
* - `test<Variant>JvmUnitTest` runs the rest with the Gradle defaults.
*
* A Robolectric test is a class annotated with `@RunWith` and one of [ROBOLECTRIC_RUNNERS]. The
* annotation is read from the class itself, not from its superclasses.
*
* With `--tests`, the AGP task runs every matching test, so the IDE can still run a plain JVM test.
*/
class RobolectricTestSplitPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.pluginManager.withPlugin("com.android.library") {
project.extensions.getByType(LibraryAndroidComponentsExtension::class.java).onVariants {
variant ->
variant.hostTests[HostTestBuilder.UNIT_TEST_TYPE]?.let { split(project, variant.name, it) }
}
}
}

private fun split(project: Project, variantName: String, unitTest: HostTest) {
unitTest.configureTestTask {
it.maxHeapSize = "2g"
it.maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
it.doFirst(ExcludeJvmTestsUnlessFiltered)
}

val variant = variantName.replaceFirstChar { it.uppercase() }
val robolectricTestsName = "test${unitTest.name.replaceFirstChar { it.uppercase() }}"
val jvmTests =
project.tasks.register("test${variant}JvmUnitTest", Test::class.java) {
description = "Runs the $variant unit tests that do not use Robolectric."
group = LifecycleBasePlugin.VERIFICATION_GROUP
val robolectricTests = project.tasks.named(robolectricTestsName, Test::class.java).get()
testClassesDirs = robolectricTests.testClassesDirs
classpath = robolectricTests.classpath
useJUnit()
exclude(RobolectricTestClass(robolectric = true))
}

project.tasks.matching { it.name == "test" }.configureEach { dependsOn(jvmTests) }
}
}

private object ExcludeJvmTestsUnlessFiltered : Action<Task> {
override fun execute(task: Task) {
val test = task as Test
// Gradle has no public getter for the `--tests` patterns.
val commandLinePatterns = (test.filter as? DefaultTestFilter)?.commandLineIncludePatterns
if (commandLinePatterns.isNullOrEmpty()) {
test.exclude(RobolectricTestClass(robolectric = false))
}
}
}

private val ROBOLECTRIC_RUNNERS =
setOf(
"androidx/test/ext/junit/runners/AndroidJUnit4",
"org/robolectric/RobolectricTestRunner",
"org/robolectric/ParameterizedRobolectricTestRunner",
)

/** Matches the class files of Robolectric tests, or with [robolectric] false, all others. */
private class RobolectricTestClass(private val robolectric: Boolean) : Spec<FileTreeElement> {
override fun isSatisfiedBy(element: FileTreeElement): Boolean {
// Never match a directory, which would exclude everything inside it.
if (element.isDirectory || !element.name.endsWith(".class")) {
return false
}
return runsWithRobolectric(element.file.readBytes()) == robolectric
}

private fun runsWithRobolectric(classFile: ByteArray): Boolean {
var found = false
val visitor =
object : ClassVisitor(Opcodes.ASM9) {
override fun visitAnnotation(descriptor: String, visible: Boolean): AnnotationVisitor? {
if (descriptor != "Lorg/junit/runner/RunWith;") {
return null
}
return object : AnnotationVisitor(Opcodes.ASM9) {
override fun visit(name: String?, value: Any?) {
if (value is Type && value.internalName in ROBOLECTRIC_RUNNERS) {
found = true
}
}
}
}
}
ClassReader(classFile)
.accept(visitor, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)
return found
}
}
4 changes: 1 addition & 3 deletions sentry-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
id("io.sentry.api-validation")
id("io.sentry.robolectric-test-split")
}

android {
Expand Down Expand Up @@ -47,9 +48,6 @@ android {
unitTests.apply {
isReturnDefaultValues = true
isIncludeAndroidResources = true
// Robolectric loads the android-all jar into each test JVM, which needs more heap
// than the default.
all { it.maxHeapSize = "2g" }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.sentry.SentryOptions
import io.sentry.android.core.performance.AppStartMetrics
import java.lang.RuntimeException
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
Expand Down Expand Up @@ -48,6 +49,12 @@ class SentryLogcatAdapterTest {

private val fixture = Fixture()

@BeforeTest
fun `set up`() {
// Another test class in the same JVM may have cached app info without the DSN metadata.
ContextUtils.resetInstance()
}

@AfterTest
fun `clean up`() {
AppStartMetrics.getInstance().clear()
Expand Down
1 change: 1 addition & 0 deletions sentry-android-replay/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.detekt)
id("io.sentry.api-validation")
id("io.sentry.robolectric-test-split")
}

android {
Expand Down
Loading