test(compose): Robolectric JVM Compose testing infra + PoC (#373)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 15:37:23 -05:00
co-authored by Claude Opus 4.8
parent 1423395454
commit cf1a8f6b83
4 changed files with 119 additions and 2 deletions
+27 -2
View File
@@ -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)
@@ -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)
}
}
@@ -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
+7
View File
@@ -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" }