From cf1a8f6b838074762a4dfd6ed9f034c0efbebd38 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 15:01:28 -0500 Subject: [PATCH] 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" }