From cf1a8f6b838074762a4dfd6ed9f034c0efbebd38 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 15:01:28 -0500 Subject: [PATCH 1/2] test(compose): Robolectric JVM Compose testing infra + PoC (#373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables unit-testing Jetpack Compose UI on the JVM via Robolectric, so render-only screens can leave the jacocoNonJvmTestableSurface exclusion list and be counted by JaCoCo without an emulator. - add Robolectric 4.16.1 (test scope) + Compose ui-test-junit4/-manifest - testOptions.unitTests.isIncludeAndroidResources = true so resources (strings, Material3 theme) resolve on the JVM - src/test/resources/robolectric.properties pins sdk=36 (targetSdk 37 is a preview level Robolectric 4.16 has no sandbox for) - PoC: AddAnotherAccountScreenJvmTest drives the screen with the v2 createComposeRule under RobolectricTestRunner (3 tests, green on the JVM) - drop AddAnotherAccountScreen from jacocoNonJvmTestableSurface (now JVM-covered); floor stays 0.79 — re-ratchet deferred to end of #373 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 29 ++++++- .../AddAnotherAccountScreenJvmTest.kt | 78 +++++++++++++++++++ app/src/test/resources/robolectric.properties | 7 ++ gradle/libs.versions.toml | 7 ++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt create mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef0c692..bce1769 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,6 +147,12 @@ android { } testOptions { + // Robolectric-backed Compose UI unit tests (issue #373) need the merged Android resources + // (drawables, strings, the compiled resource table) on the JVM unit-test classpath so + // `stringResource(...)` and Material3 theming resolve without an emulator. Off by default in + // AGP; JVM tests that don't touch resources are unaffected. + unitTests.isIncludeAndroidResources = true + // Gradle Managed Devices define the per-API E2E matrix as config-as-code: one virtual // device per supported Android version (a rolling ~7-year window, API 29 → latest stable). // Run the whole matrix with `./gradlew e2eGroupDebugAndroidTest`, or one level with e.g. @@ -251,7 +257,14 @@ val jacocoGeneratedExcludes = listOf( // audit): unlike `jacocoGeneratedExcludes` above, none of this is generated code — it is hand-written // but structurally unreachable from a JVM unit test, so counting it against the metric just measures // how much Compose/framework glue exists rather than how well the logic is tested. Four buckets: -// 1. Compose screen/component render code — only exercisable via a Compose UI test or an emulator. +// 1. Compose screen/component render code. Historically only exercisable via an emulator, so it was +// excluded here. Issue #373 changes that: Robolectric runs the Android framework on the JVM, so a +// `createComposeRule()` test in the `test` source set now gives these files real JVM coverage +// without an emulator. This bucket therefore SHRINKS one screen at a time — each glob is deleted +// in the same PR that adds that screen's Robolectric JVM Compose test. AddAnotherAccountScreen was +// the first (see AddAnotherAccountScreenJvmTest) and has been removed below; the rest are tracked +// as per-area conversion tickets under #373. The coverage-floor re-ratchet is deferred until the +// whole conversion is done and stable (#373) — do NOT raise it in a conversion PR. // 2. Android framework entry points the OS instantiates directly (Activity/Service/Application/ // BackupAgent) rather than the app's own code constructing them. // 3. Hilt DI modules — `@Provides`/`@Binds` one-liners with no branching logic. @@ -298,7 +311,7 @@ val jacocoNonJvmTestableSurface = listOf( "**/AppLockGateHost*", "**/FolderDrawer*", "**/MailboxScreen*", - "**/AddAnotherAccountScreen*", + // AddAnotherAccountScreen converted to a Robolectric JVM Compose test (#373) — now JVM-covered. "**/BatteryOptimizationScreen*", "**/ContactsAccessScreen*", "**/LicenseScreen*", @@ -459,6 +472,18 @@ dependencies { // The real org.json for unit tests (android.jar ships a stubbed, no-op version). testImplementation("org.json:json:20231013") + // Robolectric-backed JVM Compose UI tests (issue #373): Robolectric runs the Android framework + // on the JVM so `createComposeRule()` can drive composables without an emulator, bringing screen + // render code into the JaCoCo JVM-testable surface. The Compose test artifacts come from the same + // BOM as the app (aligned versions) and reuse the ui-test-junit4 / ui-test-manifest aliases the + // androidTest source set already declares — here in `test` (JVM), not `androidTest`. Robolectric + // sources Android's real org.json from its sandbox, so it does not clash with the stub-replacing + // org.json above (that is for the plain, non-Robolectric JVM tests). + testImplementation(libs.robolectric) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.compose.ui.test.manifest) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.intents) diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt new file mode 100644 index 0000000..84237bd --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric feasibility PoC (issue #373): proves Jetpack Compose UI can be unit-tested on the JVM + * without an emulator. `createComposeRule()` drives [AddAnotherAccountScreen] under + * [RobolectricTestRunner]; `stringResource(...)` and Material3 theming resolve because + * `testOptions.unitTests.isIncludeAndroidResources` is enabled in `app/build.gradle.kts`. Runs under + * `:app:testDebugUnitTest`, NOT `androidTest` — the instrumented `AddAnotherAccountScreenTest` stays. + * + * `@Config(sdk = [36])` because compileSdk/targetSdk 37 (preview) has no Robolectric 4.16 sandbox + * (also the default in `src/test/resources/robolectric.properties`); `@GraphicsMode(NATIVE)` is + * required for Compose to render under Robolectric. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class AddAnotherAccountScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private fun setContent(onAddAnother: () -> Unit = {}, onFinish: () -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + AddAnotherAccountScreen(onAddAnother = onAddAnother, onFinish = onFinish) + } + } + } + + @Test + fun rendersAccountAddedTitleFromResources() { + setContent() + + // Proves the merged Android resource table loaded on the JVM (isIncludeAndroidResources). + composeTestRule.onNodeWithText(string(R.string.onboarding_account_added_title)).assertIsDisplayed() + } + + @Test + fun tappingYes_invokesOnAddAnother() { + var addAnother = false + setContent(onAddAnother = { addAnother = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).performClick() + + assertTrue(addAnother) + } + + @Test + fun tappingNo_invokesOnFinish() { + var finished = false + setContent(onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + assertTrue(finished) + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..eb85a88 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Default SDK for every Robolectric (JVM) test (issue #373). compileSdk/targetSdk are 37, a +# preview level Robolectric 4.16.x does not ship a sandbox for; 36 (Baklava) is its newest +# supported level and needs JDK 21 (the project's Gradle JDK). Individual tests may still override +# with @Config(sdk = [..]). Only affects tests run with RobolectricTestRunner; plain JUnit unit +# tests are unaffected. +sdk=36 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f61fb86..6039f7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,11 @@ androidxTestRules = "1.7.0" turbine = "1.2.1" mockk = "1.14.11" greenmail = "2.1.9" +# Robolectric — runs the Android framework on the JVM so Compose UI (createComposeRule) can be +# unit-tested without an emulator (issue #373). 4.16.x is the first line to support compileSdk 36 +# (Baklava) and requires JDK 21 for it; API 37 (preview) is unsupported, so Robolectric tests pin +# @Config(sdk = 36) — also set in app/src/test/resources/robolectric.properties. +robolectric = "4.16.1" errorprone = "2.50.0" # Lint/format. ktlint owns formatting; detekt owns static analysis (formatting ruleset off). # detekt 2.0 is the only line with Gradle 9 support (stable 1.23.x tops out at Gradle 8.12). @@ -115,6 +120,8 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } # mockk's Android artifact for instrumented tests (dexmaker/byte-buddy-android backed). mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } +# Robolectric — JVM Android framework for unit-testing Compose UI without an emulator (issue #373). +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" } -- 2.47.3 From 324f7c2c5192c22f7903c8058f92057f4bfc308e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 15:49:07 -0500 Subject: [PATCH 2/2] fix(test): resolve Robolectric android-all via Gradle so the JVM Compose test runs in CI (#373) Robolectric resolved its android-all-instrumented runtime jar lazily at test time via its own MavenDependencyResolver/MavenArtifactFetcher, and that download is unreliable on CI runners: AddAnotherAccountScreenJvmTest failed with `AssertionError at MavenArtifactFetcher ... IOException` ("Failed to fetch maven artifact"), though it passed locally where ~/.m2 was warm. Resolve the jar through Gradle instead (reliable, cached, persisted by the CI Gradle cache) and hand it to Robolectric in offline mode so it never hits the network at test time: - Pin org.robolectric:android-all-instrumented:16-robolectric-13921718-i7 (exactly what Robolectric 4.16.1 DefaultSdkProvider maps @Config(sdk=36) to) in the version catalog. - Add it to a dedicated resolvable configuration (NOT testImplementation/ testRuntimeOnly, which would flatten the ~200MB instrumented framework onto the JVM test classpath and collide with the stub android.jar). - syncRobolectricAndroidAll stages the jar under its Maven filename, and robolectric.offline + robolectric.dependency.dir point Robolectric's LocalDependencyResolver at it. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 45 +++++++++++++++++++++++++++++++++++++++ gradle/libs.versions.toml | 14 ++++++++++++ 2 files changed, 59 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bce1769..57cea52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -412,6 +412,45 @@ tasks.named("check") { dependsOn("jacocoTestCoverageVerification") } +// --- Robolectric android-all offline resolution (issue #373) ------------------------------------ +// Robolectric runs the real Android framework on the JVM from a large `android-all-instrumented` +// jar. By default it resolves that jar LAZILY AT TEST TIME by downloading it from Maven Central +// (org.robolectric.internal.dependency.MavenDependencyResolver -> MavenArtifactFetcher). That +// runtime download is unreliable on CI runners and failed the JVM Compose PoC in CI with +// `java.lang.AssertionError at MavenArtifactFetcher ... Caused by: java.io.IOException` ("Failed to +// fetch maven artifact"). Fix: resolve the jar through Gradle instead — reliable, cached, and +// persisted by the CI Gradle cache, using the same repositories as every other dependency — then +// hand it to Robolectric in OFFLINE mode so it never touches the network at test time. +// +// A DEDICATED resolvable configuration (deliberately NOT testImplementation/testRuntimeOnly) keeps +// the ~200 MB instrumented framework jar OFF the JVM unit-test classpath: it must be loaded only by +// Robolectric's sandbox classloader, never flattened onto the app's test classpath where it would +// collide with the stub `android.jar`. `syncRobolectricAndroidAll` stages the resolved jar under +// its Maven filename (android-all-instrumented-.jar) — exactly what Robolectric's +// LocalDependencyResolver looks up as -.jar — and the two system properties +// below switch Robolectric onto that offline directory (see LegacyDependencyResolver). Every +// Robolectric test pins @Config(sdk = 36) (app/src/test/resources/robolectric.properties), so the +// single sdk=36 jar covers them all; a test on a different SDK must add that android-all version to +// this configuration too. The offline properties are inert for non-Robolectric JVM tests. +val robolectricAndroidAll: Configuration = configurations.create("robolectricAndroidAll") { + isCanBeConsumed = false + isCanBeResolved = true +} + +val robolectricDepsDir = layout.buildDirectory.dir("robolectric-android-all") + +val syncRobolectricAndroidAll = tasks.register("syncRobolectricAndroidAll") { + description = "Stages Robolectric's android-all-instrumented jar for offline resolution (issue #373)." + from(robolectricAndroidAll) + into(robolectricDepsDir) +} + +tasks.withType().configureEach { + dependsOn(syncRobolectricAndroidAll) + systemProperty("robolectric.offline", "true") + systemProperty("robolectric.dependency.dir", robolectricDepsDir.get().asFile.absolutePath) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -480,6 +519,12 @@ dependencies { // sources Android's real org.json from its sandbox, so it does not clash with the stub-replacing // org.json above (that is for the plain, non-Robolectric JVM tests). testImplementation(libs.robolectric) + // The android-all-instrumented framework jar Robolectric loads into its sandbox — resolved via + // Gradle and staged for offline use by syncRobolectricAndroidAll above so no flaky test-time + // download happens in CI (issue #373). On its own dedicated configuration, NOT the test + // classpath — see that block for why. The artifact has no transitive dependencies (verified from + // its POM), so it resolves to exactly the one staged jar. + "robolectricAndroidAll"(libs.robolectric.android.all.instrumented) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6039f7b..baf07e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,17 @@ greenmail = "2.1.9" # (Baklava) and requires JDK 21 for it; API 37 (preview) is unsupported, so Robolectric tests pin # @Config(sdk = 36) — also set in app/src/test/resources/robolectric.properties. robolectric = "4.16.1" +# The instrumented android-all runtime jar Robolectric loads into its sandbox to run the Android +# framework on the JVM. By DEFAULT Robolectric downloads this ~200 MB artifact from Maven Central +# lazily at TEST time (MavenDependencyResolver -> MavenArtifactFetcher); that runtime fetch is +# unreliable on CI runners and failed the JVM Compose PoC with "Failed to fetch maven artifact" +# (issue #373). Pinning it lets Gradle resolve+cache it and hand it to Robolectric in offline mode +# (see the robolectricAndroidAll wiring in app/build.gradle.kts) so no network fetch happens at +# test time. The value is BOTH Robolectric-version- and SDK-specific: it is exactly what +# Robolectric 4.16.1's DefaultSdkProvider maps @Config(sdk = 36) to — androidVersion "16", build +# "13921718", preinstrumented suffix "-i7". Bump it together with `robolectric` above, and update +# it if the pinned @Config(sdk = ..) / robolectric.properties SDK ever changes. +robolectricAndroidAll = "16-robolectric-13921718-i7" errorprone = "2.50.0" # Lint/format. ktlint owns formatting; detekt owns static analysis (formatting ruleset off). # detekt 2.0 is the only line with Gradle 9 support (stable 1.23.x tops out at Gradle 8.12). @@ -122,6 +133,9 @@ mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "moc greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } # Robolectric — JVM Android framework for unit-testing Compose UI without an emulator (issue #373). robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +# Resolved via Gradle (not Robolectric's runtime Maven fetch) and staged for offline use so the JVM +# Compose tests run in CI without a flaky test-time download — see app/build.gradle.kts (issue #373). +robolectric-android-all-instrumented = { group = "org.robolectric", name = "android-all-instrumented", version.ref = "robolectricAndroidAll" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" } -- 2.47.3