Using Robolectric Correctly — Android On The JVM, Without The Footguns
Robolectric simulates Android on the JVM by swapping in pure-Java reimplementations of system classes ("shadows"). It is fast (10-100x vs an emulator) but not an emulator: no real RenderThread, no real Binder, no real GPU. This skill locks down the runner choice, the @Config matrix, the looper draining ritual, and the AGP 7.2+ sharedTest gotcha. Compose-on-Robolectric specifics live in ../../../compose/synchronization/controlling-the-test-clock/SKILL.md.
When to use this skill
- The class under test imports
android.*(Context, Resources, View, PackageManager, Looper) and the developer wants to test it on the JVM in seconds, not on an emulator in minutes. - A unit test fails with
RuntimeException: Method ... not mocked(the bare-Android-jar default) — the developer needs Robolectric's shadows. - A unit test fails with
Resources$NotFoundException—testOptions.unitTests.includeAndroidResourcesis missing. - The developer asks "AndroidJUnit4 vs RobolectricTestRunner — which?".
- A
Handler.postDelayed/Looperqueue does not advance under Robolectric's defaultLooperMode.PAUSED. - The developer asks how to run the same test source on JVM and on a device (
sharedTest/androidHostTest+androidDeviceTest). - Build error after AGP 7.2+: "Source directory ... already added to source set", caused by the classic
sharedTestsrcDirsharing pattern.
When NOT to use this skill
- The class under test has zero
android.*imports. Robolectric pays a 1-3 s class-load tax for nothing — use plain JUnit4. See../../runner/configuring-junit4-on-android/SKILL.md. - The test verifies pixel correctness, ripple animations, screenshot diffs, accessibility services, or
RenderThreadtiming. Run on an emulator/device. See../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md. - The test is about coroutines +
runTestand does not need framework Android. Use../../coroutines/testing-coroutines-with-runtest/SKILL.md. - The test is about Compose UI rendering. The host-test environment is set up by
../../../compose/setup/configuring-test-dependencies/SKILL.mdand the clock semantics live in../../../compose/synchronization/controlling-the-test-clock/SKILL.md.
Prerequisites
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.x")
testImplementation("androidx.test:core:1.7.0") // ApplicationProvider works on both runtimes
testImplementation("androidx.test.ext:junit:1.3.0") // androidx.test.ext.junit.runners.AndroidJUnit4
(docs/CORPUS.md §G.1; R7.)
android {
testOptions {
unitTests {
includeAndroidResources = true // non-negotiable
}
}
}
includeAndroidResources is required if any test (or any class loaded by any test) touches R.*, Resources, layouts, themes, or anything from res/. Without it, resource lookups throw at test startup (R7).
Robolectric 4.x supports SDKs 21 through the latest stable. The androidx Compose host tests pin a floor of 23 — quote it as a single source of truth:
// androidx/compose/ui/ui-test/src/androidHostTest/.../Constants.kt
internal const val RobolectricMinSdk = 23
(R7.)
Runner choice — AndroidJUnit4 vs RobolectricTestRunner
Two runners work; they read @Config identically.
| Runner | When |
|---|---|
androidx.test.ext.junit.runners.AndroidJUnit4 |
Default. A router: delegates to Robolectric on JVM and to the real AndroidJUnit4ClassRunner on a device. Linchpin of the sharedTest pattern. |
org.robolectric.RobolectricTestRunner |
Only when the test must never run on a device — e.g. uses Robolectric-specific shadow APIs like shadowOf(Looper.getMainLooper()).idle() directly, or needs a Robolectric subclass like ParameterizedRobolectricTestRunner. |
(R7; docs/CORPUS.md §G.6.)
import androidx.test.ext.junit.runners.AndroidJUnit4
@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
class FooTest { /* runs under Robolectric on JVM, real runner on device */ }
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class LooperBoundTest { /* uses ShadowLooper APIs directly — JVM only */ }
CRITICAL: androidx.test.runner.AndroidJUnit4 (the runner package, not ext.junit.runners) is @Deprecated (docs/CORPUS.md §G.2). Always import from androidx.test.ext.junit.runners.
@Config — sdk, qualifiers, application
@Config configures the simulated Android environment per class or per method. Method-level overrides class-level; subclasses inherit and may override.
// Single SDK
@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
class FooTest
// SDK matrix — runs once per listed SDK
@Config(sdk = [21, 28, 33])
class BarTest
// Range — runs once at the project's targetSdk if it is >= minSdk
@Config(minSdk = 23)
class BazTest
// Per-method override
class QuxTest {
@Test @Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun onTiramisuOnly() { /* ... */ }
@Test @Config(qualifiers = "fr-rFR-w820dp") // locale + screen
fun frenchLayout() { /* ... */ }
@Test @Config(qualifiers = "+night") // additive: keep, force night
fun darkColors() { /* ... */ }
}
// Override the Application instance
@Config(application = MyTestApplication::class)
class WithFakeApp
(R7.)
Constraint: sdk and minSdk/maxSdk cannot coexist on the same annotation. minSdk and maxSdk may be combined.
CI-time hot take: prefer @Config(sdk = [<one SDK>]) on the commit gate; reserve multi-SDK matrices for the merge gate. Each SDK in the matrix re-runs the whole class.
Shadows
Shadows are JVM-friendly fakes for Android system classes. Robolectric ships hundreds; you can write your own with @Implements/@Implementation. Common ones:
| Shadow | Purpose |
|---|---|
ShadowApplication |
Inspect started services / broadcasts / granted permissions. |
ShadowLog |
Capture android.util.Log output (ShadowLog.stream = System.out). |
ShadowSystemClock |
Advance / freeze SystemClock.elapsedRealtime() / uptimeMillis(). |
ShadowLooper |
Drive message queues; idleMainLooper(), runUiThreadTasksIncludingDelayedTasks(). |
ShadowPackageManager |
Add/remove packages, set system features, queryable intents. |
ShadowAlarmManager |
Inspect scheduled alarms without firing them. |
ShadowNotificationManager |
Inspect posted notifications and channels. |
ShadowInputManager |
Add/remove InputDevices for input tests. |
ShadowContentResolver |
Register fake providers, observe inserts/queries. |
Shadows.shadowOf(realObject) returns the shadow instance. Static-import and rely on overload resolution:
import androidx.test.core.app.ApplicationProvider
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowPackageManager
val app: Application = ApplicationProvider.getApplicationContext()
val shadowPm: ShadowPackageManager = shadowOf(app.packageManager)
shadowPm.setSystemFeature(PackageManager.FEATURE_CAMERA_ANY, true)
Looper draining — the most-missed step
Robolectric's main looper is paused in the default LooperMode.PAUSED runtime mode. Handler messages posted by the SUT do NOT run automatically. Drain explicitly:
import android.os.Looper
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowLooper
import java.time.Duration
// Drain everything currently queued on the main looper.
shadowOf(Looper.getMainLooper()).idle()
// Run delayed tasks too (postDelayed et al).
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
// Advance virtual time by N ms; only handlers due before that time fire.
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
(R7.)
This complements (and is independent from) Compose's MainTestClock.advanceTimeBy(...). The Compose clock and the platform looper are separate. For Compose-on-Robolectric, runComposeUiTest { … waitForIdle() } drives the looper for you via RobolectricIdlingStrategy — outside Compose, drain manually.
sharedTest / androidHostTest / androidDeviceTest
The classic sharedTest pattern (per the 2021 Robolectric blog post) puts one source directory under both test and androidTest:
// LEGACY — broken on AGP 7.2+
android {
sourceSets {
String dir = 'src/sharedTest/'
test { java.srcDirs += dir + 'java' }
androidTest { java.srcDirs += dir + 'java' }
}
}
CRITICAL: starting with AGP 7.2+, you cannot put the same source directory into multiple source sets (Robolectric GitHub issue #7432; R7). The build fails with "Source directory ... already added to source set".
Modern alternatives (PREFERRED order):
- AndroidX-style KMP source sets —
commonTest->{androidHostTest, androidDeviceTest}via theandroidXMultiplatformplugin. Compose internally migrated to this:sourceSets { androidCommonTest { dependsOn(commonTest) } androidDeviceTest { dependsOn(androidCommonTest) } // instrumented APK androidHostTest { dependsOn(androidCommonTest) } // Robolectric on JVM } - Separate Gradle module that both
testandandroidTestdepend on astestImplementation/androidTestImplementation. - Symlink or copy sources at configuration time (least preferred — fragile).
Both androidHostTest and androidDeviceTest can use @RunWith(AndroidJUnit4::class) unchanged because AndroidJUnit4 is a router. Source-set wiring is ../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md.
When NOT to reach for Robolectric
| Scenario | Where it belongs |
|---|---|
Pure Kotlin, no android.* imports |
Plain JUnit4 in src/test/ (no Robolectric). |
| Pixel-correct rendering, screenshot tests | Emulator/device — RenderThread is real there, not on Robolectric. |
| Accessibility service behavior | Emulator/device. Compose's enableAccessibilityChecks installs the validator on Robolectric AND logs a warning (Build.FINGERPRINT.lowercase() == "robolectric"); Robolectric does not faithfully drive the accessibility services so any pass is inconclusive (b/332778271). |
Choreographer fidelity, animation timing close to production |
Emulator/device. |
| GPU/Skia behavior | Emulator/device. @GraphicsMode(GraphicsMode.Mode.NATIVE) opt-in is a stub of the real pipeline. |
Patterns
Pattern: WRONG — RobolectricTestRunner for a test that should also run on device
// WRONG
@RunWith(RobolectricTestRunner::class)
@Config(minSdk = 23)
class LoginViewModelTest {
@Test fun loginFlow() { /* ... */ }
}
// WRONG because: this class CAN run unchanged on a device too — there are no
// Robolectric-only APIs in the body. Pinning to RobolectricTestRunner locks the
// test to the JVM and forfeits the sharedTest pattern. The same class moved to
// src/androidTest/ (or androidDeviceTest) would not run.
// RIGHT
@RunWith(AndroidJUnit4::class)
@Config(minSdk = RobolectricMinSdk) // pin via single source of truth
class LoginViewModelTest {
@Test fun loginFlow() { /* ... */ }
}
Pattern: WRONG — assert before draining the looper
// WRONG
@RunWith(AndroidJUnit4::class)
class HandlerTest {
@Test fun postedRuns() {
var fired = false
Handler(Looper.getMainLooper()).post { fired = true }
assertTrue(fired) // FAIL: runnable is queued, not run
}
}
// WRONG because: Robolectric's default LooperMode.PAUSED does not auto-run posted
// runnables. The post is queued; assertTrue runs before the queue is drained.
// RIGHT
@RunWith(AndroidJUnit4::class)
class HandlerTest {
@Test fun postedRuns() {
var fired = false
Handler(Looper.getMainLooper()).post { fired = true }
shadowOf(Looper.getMainLooper()).idle() // drain queue
assertTrue(fired)
}
}
Pattern: WRONG — missing includeAndroidResources
// WRONG
android {
testOptions {
unitTests { /* includeAndroidResources missing */ }
}
}
Symptom: android.content.res.Resources$NotFoundException: String resource ID #0x7f0c0001 at test startup. The fix:
// RIGHT
android {
testOptions {
unitTests {
includeAndroidResources = true // Groovy
// isIncludeAndroidResources = true // Kotlin DSL
}
}
}
Pattern: WRONG — sharedTest srcDir sharing on AGP 7.2+
// WRONG
android {
sourceSets {
test { java.srcDirs += 'src/sharedTest/java' }
androidTest { java.srcDirs += 'src/sharedTest/java' }
}
}
// WRONG because: AGP 7.2+ rejects sharing a single source directory across
// source sets (Robolectric issue #7432). Build fails with "Source directory
// already added to source set".
// RIGHT — AndroidX-style KMP source sets
sourceSets {
androidCommonTest { dependsOn(commonTest) }
androidDeviceTest { dependsOn(androidCommonTest) }
androidHostTest { dependsOn(androidCommonTest) }
}
Pattern: ParameterizedRobolectricTestRunner — when matrix is the point
// androidx/compose/ui/ui-test/.../ViewVisibilityRobolectricTest.kt (paraphrased)
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(minSdk = RobolectricMinSdk)
class ViewVisibilityRobolectricTest(private val visibility: Int) {
companion object {
@JvmStatic
@Parameters(name = "visibility={0}")
fun params() = listOf(View.VISIBLE, View.INVISIBLE, View.GONE)
}
@Test fun behaves() { /* uses `visibility` */ }
}
This is the one place to pin RobolectricTestRunner (its parameterized subclass) — there is no AndroidJUnit4 parameterized variant.
Pattern: production code branching on Robolectric — read with caution
// androidx/compose/ui/ui-test/.../RobolectricIdlingStrategy.android.kt
internal val HasRobolectricFingerprint
get() = Build.FINGERPRINT.lowercase() == "robolectric"
This idiom is widespread inside androidx (Compose accessibility, idling, graphics layers). Library code under test SHOULD avoid copying it unless the branch is genuinely test-only — misdetection silently changes behavior.
Mandatory rules
- MUST import
AndroidJUnit4fromandroidx.test.ext.junit.runners. MUST NOT import fromandroidx.test.runner— that one is@Deprecated(docs/CORPUS.md§G.2). - MUST prefer
@RunWith(AndroidJUnit4::class)over@RunWith(RobolectricTestRunner::class). The latter only when the test uses Robolectric-specific APIs (e.g.shadowOf(Looper.getMainLooper()).idle()) directly, or needsParameterizedRobolectricTestRunner. - MUST set
testOptions.unitTests.includeAndroidResources = truefor any project whose unit tests touchR.*/Resources/ layouts / themes. - MUST drain the looper with
shadowOf(Looper.getMainLooper()).idle()(orShadowLooper.runUiThreadTasksIncludingDelayedTasks()) before asserting anything that depends on aHandler.post*callback. DefaultLooperMode.PAUSEDdoes NOT auto-run. - MUST pin a class-level
@Config(minSdk = …)(orsdk = […]) — do not depend on the project's default SDK varying in CI. - MUST NOT use the legacy
srcDirssharing patterntest.java.srcDirs += 'src/sharedTest/java'on AGP 7.2+. Migrate toandroidHostTest+androidDeviceTest(KMP source sets) or a separate Gradle module. - MUST NOT add Robolectric to a test class that has zero
android.*imports. The class-load tax is 1-3 s for nothing. - MUST NOT use Robolectric for screenshot tests, ripple animations, accessibility services, or
RenderThreadtiming — use an emulator/device. - PREFERRED: quote
RobolectricMinSdk = 23fromandroidx/compose/ui/ui-test/src/androidHostTest/.../Constants.ktas the single source of truth for the floor SDK inandroidHostTestmodules. - PREFERRED: group related tests into the same class to amortize the per-class class-load tax. Avoid sprinkling
@RunWith(RobolectricTestRunner::class)across many small classes. - PREFERRED: single-SDK
@Config(sdk = [33])on the commit gate; multi-SDK matrices on the merge gate.
Verification
- No import of
androidx.test.runner.AndroidJUnit4(useandroidx.test.ext.junit.runners.AndroidJUnit4).grep -rn 'androidx.test.runner.AndroidJUnit4' src/test src/androidTestreturns empty. -
testOptions.unitTests.includeAndroidResources = trueis set in the module'sbuild.gradle(.kts). - No
@RunWith(RobolectricTestRunner::class)exists on a test class that does not actually use Robolectric-specific APIs. - Every test that posts to a
Handler/ triggersLooperwork callsshadowOf(Looper.getMainLooper()).idle()(orrunUiThreadTasksIncludingDelayedTasks) before asserting. - Every Robolectric test class declares
@Config(minSdk = …)or@Config(sdk = […]). - Source-set wiring uses
androidHostTest+androidDeviceTest(or a shared module), not legacysrcDirs += sharedTest. - No screenshot/ripple/accessibility-service test runs under Robolectric — those live in
src/androidTest/orandroidDeviceTest. -
./gradlew :module:testpasses;./gradlew :module:connectedDebugAndroidTestpasses for any test inandroidCommonTest.
References
- Robolectric — getting started: http://robolectric.org/getting-started/
- Robolectric — configuring: http://robolectric.org/configuring/
- Robolectric — sharedTest blog post: http://robolectric.org/blog/2021/10/06/sharedTest/
- Android Developers — Build local unit tests: https://developer.android.com/training/testing/local-tests
- Android Developers — Robolectric: https://developer.android.com/training/testing/local-tests/robolectric
- Robolectric GitHub issue #7432 — AGP 7.2 sharedTest breakage: https://github.com/robolectric/robolectric/issues/7432
- Research:
tasks/research/R7-robolectric.md. docs/CORPUS.md§G.1 / §G.6 — Gradle coordinates and runner choice.androidx/compose/ui/ui-test/src/androidHostTest/.../Constants.kt—RobolectricMinSdk = 23floor.androidx/compose/ui/ui-test/.../RobolectricComposeTest.kt— canonical@RunWith(AndroidJUnit4::class) @Config(minSdk = RobolectricMinSdk)cross-runtime test.androidx/compose/ui/ui-test/.../PrefetchNotHangingMainThreadTest.kt—@RunWith(RobolectricTestRunner::class)with explicitshadowOf(Looper.getMainLooper()).idle().androidx/compose/ui/ui-test/.../ViewVisibilityRobolectricTest.kt—ParameterizedRobolectricTestRunnerexample.androidx/compose/ui/ui-test/.../RobolectricIdlingStrategy.android.kt—Build.FINGERPRINT.lowercase() == "robolectric"detection idiom.androidx/compose/ui/ui/.../MediaQueryIntegrationTest.kt—ShadowPackageManager/ShadowInputManagerin an@RunWith(AndroidJUnit4::class)test.- Sibling:
../../coroutines/testing-coroutines-with-runtest/SKILL.md—runTest+MainDispatcherRule(independent of Robolectric). - Sibling:
../../coroutines/testing-flows-with-turbine/SKILL.md— Flow assertions; Turbine works under Robolectric unchanged. - Sibling:
../../runner/configuring-junit4-on-android/SKILL.md— JUnit4 plumbing surrounding Robolectric. - Sibling:
../../mocking/mocking-with-mockito/SKILL.md— mocking Android types under Robolectric. - Sibling:
../../mocking/mocking-with-mockk/SKILL.md—mockkStatic(Build::class)for fingerprint branches. - Cross-set:
../../../fundamentals/strategies/applying-testing-strategies/SKILL.md— Robolectric is "medium" tests in Google's three-size framing. - Cross-set:
../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md—src/test/vssrc/androidTest/vsandroidHostTest/androidDeviceTest. - Cross-set:
../../../compose/synchronization/controlling-the-test-clock/SKILL.md— Compose'sMainTestClockis separate fromShadowLooper. - Cross-set:
../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md— when the same test source needs to run on a device.