From 818e21e34eab74d943ef541ab31ef6951193fcf2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:55:26 +0000 Subject: [PATCH 1/3] build(android): Run Robolectric unit tests in their own forked task Split the unit tests of sentry-android-core and sentry-android-replay into two tasks. The AGP test task keeps the Robolectric tests and gets a 2g heap and one fork per two CPUs. A new testReleaseJvmUnitTest task runs the other tests with the Gradle defaults. Co-Authored-By: Roman Zavarnitsyn --- build-logic/build.gradle.kts | 4 + .../gradle/RobolectricTestSplitPlugin.kt | 117 ++++++++++++++++++ sentry-android-core/build.gradle.kts | 4 +- sentry-android-replay/build.gradle.kts | 1 + 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 8acc2b4e999..01ebc0633e8 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -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" + } } } diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt new file mode 100644 index 00000000000..b939205ecec --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt @@ -0,0 +1,117 @@ +package io.sentry.gradle + +import com.android.build.api.dsl.LibraryExtension +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: + * - `testUnitTest`, the AGP task, runs the Robolectric tests. Robolectric loads an + * android-all jar into each test JVM, which needs more heap than the default. + * - `testJvmUnitTest` 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 { + override fun apply(project: Project) { + project.pluginManager.withPlugin("com.android.library") { + // testBuildType is only final once the module's android block has run. + project.afterEvaluate { split(project) } + } + } + + private fun split(project: Project) { + val android = project.extensions.getByType(LibraryExtension::class.java) + val variant = android.testBuildType!!.replaceFirstChar { it.uppercase() } + val robolectricTestsName = "test${variant}UnitTest" + + project.tasks + .withType(Test::class.java) + .matching { it.name == robolectricTestsName } + .configureEach { + maxHeapSize = "2g" + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + doFirst(ExcludeJvmTestsUnlessFiltered) + } + + 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 { + 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 { + 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 + } +} diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f78dd0c036b..e2d760c3f91 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -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 { @@ -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" } } } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 6fff59a0c41..e5ea1e91c24 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -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 { From 7a6cf3da2b14bb49f04c696f8e06ae76da19f28f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:15:34 +0000 Subject: [PATCH 2/3] test(android): Reset ContextUtils before each SentryLogcatAdapterTest With more forks, the test order changes, and a class that ran before this one in the same JVM can leave cached app info without the DSN metadata. Reset it before each test, like the other tests that mock manifest metadata. --- .../java/io/sentry/android/core/SentryLogcatAdapterTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 0c0c03d71d2..ca42381842a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -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 @@ -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() From a58bfc61f25bf0ef1b8eab3a55612b0c1fd0a91b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:25:18 +0000 Subject: [PATCH 3/3] build(android): Configure the Robolectric test split from the variant API Use androidComponents.onVariants and the unit test component's configureTestTask instead of afterEvaluate and a task name match. Co-Authored-By: Nelson Osacky Co-Authored-By: Roman Zavarnitsyn --- .../gradle/RobolectricTestSplitPlugin.kt | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt index b939205ecec..58c65303232 100644 --- a/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt +++ b/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt @@ -1,6 +1,8 @@ package io.sentry.gradle -import com.android.build.api.dsl.LibraryExtension +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 @@ -31,25 +33,22 @@ import org.objectweb.asm.Type class RobolectricTestSplitPlugin : Plugin { override fun apply(project: Project) { project.pluginManager.withPlugin("com.android.library") { - // testBuildType is only final once the module's android block has run. - project.afterEvaluate { split(project) } + 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) { - val android = project.extensions.getByType(LibraryExtension::class.java) - val variant = android.testBuildType!!.replaceFirstChar { it.uppercase() } - val robolectricTestsName = "test${variant}UnitTest" - - project.tasks - .withType(Test::class.java) - .matching { it.name == robolectricTestsName } - .configureEach { - maxHeapSize = "2g" - maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) - doFirst(ExcludeJvmTestsUnlessFiltered) - } + 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."