| name | setting-up-host-vs-device-tests |
| description | Use this skill to choose between host (Robolectric/JVM) and device (instrumentation) tests for Jetpack Compose, and to configure each correctly. Covers the `androidHostTest` (a.k.a. `src/test/`) vs `androidDeviceTest` (a.k.a. `src/androidTest/`) source set split, what each flavor can and cannot drive (RenderThread, screenshots, accessibility), the `@RunWith(AndroidJUnit4::class) @Config(minSdk = 23)` setup for Robolectric, and why `Thread.sleep` is forbidden everywhere except screenshot tests waiting on the RenderThread. Use when the user reports "tests pass locally but fail on CI", asks "Robolectric vs instrumentation", mentions screenshot tests, ripple animations, accessibility checks throwing on Robolectric, or "should this test live in test/ or androidTest/". |
| license | Apache-2.0. See LICENSE for complete terms. |
| metadata | {"author":"Jaewoong Eum (skydoves)","keywords":["jetpack-compose","ui-testing","robolectric","host-test","device-test","androidTest","instrumentation-test","render-thread","screenshot-test","thread-sleep","mainClock-advanceTimeBy"]} |
Setting Up Host vs Device Tests — Pick the Right Source Set
Compose tests run unchanged on either Robolectric (JVM, fast, no emulator) or on a real/virtual device (full Android stack, RenderThread, accessibility). The same runComposeUiTest { setContent { … } } block compiles in both — only the underlying Looper and Choreographer differ. This skill encodes which flavor each test should live in, the Robolectric class skeleton, and the one legitimate Thread.sleep exception (screenshot tests waiting on the RenderThread).
When to use this skill
- The user is starting a Compose UI test and asks "test/ or androidTest/?".
- A test passes on a local emulator but fails on CI's Robolectric runner (or vice versa).
- The user reports
Build.FINGERPRINT == "robolectric" warnings from enableAccessibilityChecks(...).
- A screenshot test produces a black/empty PNG on the host runner.
- A ripple/
pressInteraction test renders no ripple on Robolectric and the user is debugging why.
- The user wrote
Thread.sleep(1000) to "wait for an animation" and is asking why it is flaky.
- The user mentions
androidDeviceTest / androidHostTest source sets (used in androidx itself).
When NOT to use this skill
- The dependencies are not yet wired correctly — start with
./configuring-test-dependencies/SKILL.md.
- The choice is between
createComposeRule() and runComposeUiTest { } — see ./choosing-test-rule-vs-runtest/SKILL.md.
- The test runs in the right flavor but is flaky on idle/animation — see
../../synchronization/synchronizing-with-idle/SKILL.md and ../../synchronization/testing-animations-deterministically/SKILL.md.
Prerequisites
androidx.compose.ui:ui-test, ui-test-junit4, and ui-test-manifest on the correct configurations for the chosen flavor — see ./configuring-test-dependencies/SKILL.md.
- For host tests:
org.robolectric:robolectric on testImplementation, testOptions { unitTests.isIncludeAndroidResources = true } in the Android block.
- For device tests: a configured emulator or physical device,
androidx.test.runner.AndroidJUnitRunner (or a Hilt subclass) as the testInstrumentationRunner.
- Working knowledge of
MainTestClock semantics — see the synchronization skill set.
Workflow
@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
@OptIn(ExperimentalTestApi::class)
class MyHostTest {
@Before
fun setup() {
masterTimeout = IdlingPolicies.getMasterIdlingPolicy()
}
@After
fun tearDown() {
masterTimeout?.let {
IdlingPolicies.setMasterPolicyTimeout(it.idleTimeout, it.idleTimeoutUnit)
}
}
@Test
fun stateChange() = runComposeUiTest {
setContent { ClickCounter() }
onNodeWithText("Click me").performClick()
onNodeWithText("Click count", substring = true).assertTextEquals("Click count: 1")
}
private var masterTimeout: IdlingPolicy? = null
}
AndroidJUnit4::class delegates to Robolectric on the JVM and to AndroidJUnit4ClassRunner on a device — making the same class portable. @RunWith(RobolectricTestRunner::class) also works but ties the class to host-only.
@RunWith(AndroidJUnit4::class)
class MyDeviceTest {
@get:Rule val rule = createComposeRule()
@Test
fun stateChange() {
rule.setContent { ClickCounter() }
rule.onNodeWithText("Click me").performClick()
rule.onNodeWithText("Click count", substring = true).assertTextEquals("Click count: 1")
}
}
rule.mainClock.autoAdvance = false
rule.onNode(isToggleable()).performTouchInput { down(center) }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
rule.mainClock.advanceTimeBy(milliseconds = 200)
Thread.sleep(300)
assertAgainstGolden("toggleButton_lightTheme_defaultToPressed")
This is the only case. Anywhere else, Thread.sleep is a smell — it desyncs from MainTestClock and produces flakes that wear the developer down. Use mainClock.advanceTimeBy(durationMs) (test clock) for animations or rule.waitUntil(timeoutMillis = …) { … } (wall clock) for external state.
Patterns
Pattern: WRONG vs RIGHT — screenshot test on host
@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
class ButtonScreenshotTest {
@Test
fun pressed() = runComposeUiTest {
setContent { Button(onClick = {}) { Text("OK") } }
onNode(hasText("OK")).performTouchInput { down(center) }
onNode(hasText("OK")).captureToImage().assertAgainstGolden("pressed")
}
}
@RunWith(AndroidJUnit4::class)
class ButtonScreenshotTest {
@get:Rule val rule = createComposeRule()
@Test
fun pressed() {
rule.setContent { Button(onClick = {}) { Text("OK") } }
rule.mainClock.autoAdvance = false
rule.onNode(hasText("OK")).performTouchInput { down(center) }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
rule.mainClock.advanceTimeBy(milliseconds = 200)
Thread.sleep(300)
rule.onNode(hasText("OK")).captureToImage().assertAgainstGolden("pressed")
}
}
Pattern: WRONG vs RIGHT — Thread.sleep in a host animation test
@Test
fun fadeIn() = runComposeUiTest {
val target = mutableStateOf(0f)
setContent { Box(Modifier.alpha(animateFloatAsState(target.value).value)) }
target.value = 1f
Thread.sleep(500)
onNode(isRoot()).captureToImage()
}
@Test
fun fadeIn() = runComposeUiTest {
mainClock.autoAdvance = false
val target = mutableStateOf(0f)
setContent { Box(Modifier.alpha(animateFloatAsState(target.value).value).testTag("box")) }
runOnUiThread { target.value = 1f }
mainClock.advanceTimeByFrame()
mainClock.advanceTimeBy(durationMillis = 500)
onNodeWithTag("box").assertIsDisplayed()
}
For pixel verification of the fade, move the test to the device source set and use captureToImage().
Pattern: WRONG vs RIGHT — accessibility checks on host
@Test
fun submitIsLabelled() = runComposeUiTest {
enableAccessibilityChecks()
setContent { Submit() }
onNodeWithTag("submit").performClick()
}
@RunWith(AndroidJUnit4::class)
class SubmitA11yTest {
@get:Rule val rule = createAndroidComposeRule<MainActivity>()
@Test fun submitIsLabelled() {
rule.enableAccessibilityChecks()
rule.setContent { Submit() }
rule.onNodeWithTag("submit").performClick()
}
}
Pattern: shared body, two source sets
The same runComposeUiTest { ... } body can compile in both source sets when the test only uses the common API. Place the body in androidCommonTest/ and create two thin wrappers — one in androidHostTest/ (with @RunWith(AndroidJUnit4::class) @Config(minSdk = 23)), one in androidDeviceTest/ (no @Config). This is how androidx's own ui-test module exercises both flavors without code duplication. For an app module without KMP source sets, prefer keeping the body inline in whichever flavor is appropriate.
Mandatory rules
- MUST NOT put screenshot /
captureToImage / ripple / Modifier.indication-dependent tests in the host source set. The RenderThread is not driven; the test is meaningless even when it appears to pass.
- MUST NOT rely on
enableAccessibilityChecks(...) results from a host test. Both extensions log a warning under Build.FINGERPRINT.lowercase() == "robolectric" and still install the validator, but Robolectric does not faithfully drive accessibility services so the result is inconclusive — run accessibility checks on a real device API 34+.
- MUST NOT use
Thread.sleep in a host test under any circumstance. The screenshot exception does not apply (host has no RenderThread).
- MUST NOT use
Thread.sleep in a device test except when waiting on the RenderThread for ripple/screenshot golden capture. Skydoves hot take #7: Thread.sleep is a smell. Anywhere else, replace it with mainClock.advanceTimeBy(durationMs) (test clock) or rule.waitUntil(timeoutMillis) { ... } (wall clock) — see ../../synchronization/synchronizing-with-idle/SKILL.md.
- MUST annotate Robolectric host tests with
@Config(minSdk = 23) or higher. Lower SDK levels are not supported by androidx's host test infrastructure (internal const val RobolectricMinSdk = 23).
- MUST annotate gesture-detection or animation host tests to step the clock manually with
mainClock.advanceTimeBy(...). Robolectric does not advance the test clock from real-time signals.
- MUST set
testOptions { unitTests.isIncludeAndroidResources = true } in the module's android { } block so Robolectric can read merged resources during host Compose tests.
- PREFERRED: start with a host test for fast feedback. Move to a device test only when capability requires it (RenderThread, accessibility, real input timing).
- PREFERRED: when a test must work in both flavors, factor the body into
androidCommonTest/ (KMP) or a helper function the two flavors call.
Verification
References
- Compose testing overview (Android Developers): https://developer.android.com/develop/ui/compose/testing
- Robolectric — Compose support: http://robolectric.org/
- Compose UI release notes: https://developer.android.com/jetpack/androidx/releases/compose-ui
- Testing animations (Android Developers): https://developer.android.com/develop/ui/compose/animation/testing
compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt — canonical host class skeleton, @RunWith(AndroidJUnit4::class) @Config(minSdk = RobolectricMinSdk), gesture-detector clock comment, IdlingPolicies setup/teardown.
compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/Constants.kt — internal const val RobolectricMinSdk = 23.
compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/ToggleButtonScreenshotTest.kt:115-123 — the canonical legitimate Thread.sleep(300) waiting on the RenderThread for ripple completion before assertAgainstGolden.
compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/RobolectricIdlingStrategy.android.kt — Robolectric idling strategy that drives the host idle loop.
compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt — the recomposer + snapshot + frame-clock aggregator (caps at 100 frames/call); does NOT include the RenderThread, which is why ripple tests need Thread.sleep.