JVM Android Tests with Robolectric

AndroidTestingRobolectricJUnit
Share on LinkedIn

Instrumented tests are honest but slow. Booting an emulator for every PR adds minutes to CI and encourages developers to skip tests locally. Robolectric runs real Android framework bytecode on the JVM using modified class loaders and shadow implementations — your Context.getSharedPreferences call executes against an in-memory shadow, not a mock you maintain by hand. The result: tests that exercise actual framework code paths in milliseconds.

Project setup

// build.gradle.kts
dependencies {
    testImplementation("org.robolectric:robolectric:4.12")
    testImplementation("androidx.test:core:1.5.0")
    testImplementation("androidx.test.ext:junit:1.1.5")
}
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [33])
class PreferencesRepositoryTest {

    @Test
    fun savesToken() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val prefs = context.getSharedPreferences("auth", Context.MODE_PRIVATE)
        prefs.edit().putString("token", "abc123").apply()

        val repo = PreferencesRepository(context)
        assertEquals("abc123", repo.getToken())
    }
}

ApplicationProvider.getApplicationContext() returns a Robolectric-managed context backed by shadows.

SDK and device configuration

Control screen size, locale, and API level:

@Config(
    sdk = [28],
    qualifiers = "fr-rFR-w360dp-h640dp-port-xhdpi"
)
class LocaleFormattingTest {
    @Test
    fun formatsDateInFrench() {
        val formatted = DateFormatter.format(Instant.parse("2024-03-15T10:00:00Z"))
        assertTrue(formatted.contains("mars"))
    }
}

I've debugged "works on CI, fails locally" issues caused by mismatched @Config(sdk) — standardize on one SDK per module unless testing version-specific branches.

Testing components

BroadcastReceiver:

@Test
fun receiverHandlesBootComplete() {
    val receiver = SyncAlarmReceiver()
    val intent = Intent(Intent.ACTION_BOOT_COMPLETED)
    receiver.onReceive(ApplicationProvider.getApplicationContext(), intent)
    assertTrue(AlarmScheduler.wasScheduled)
}

Activity and Fragment (with AndroidX Test):

@RunWith(AndroidJUnit4::class)
@Config(sdk = [33])
class MainActivityTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun showsWelcomeText() {
        onView(withId(R.id.welcome)).check(matches(withText("Welcome")))
    }
}

Hilt integration

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication_Application::class)
class UserViewModelTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Inject lateinit var repository: FakeUserRepository
    @Inject lateinit var viewModel: UserViewModel

    @Before fun setup() { hiltRule.inject() }

    @Test
    fun loadsUser() = runTest {
        repository.enqueueUser(User(id = "1", name = "Alex"))
        viewModel.load("1")
        assertEquals("Alex", viewModel.state.value.name)
    }
}

Replace network modules with fakes via @TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkModule::class]).

Compose on Robolectric

@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [33])
class LoginScreenTest {

    @get:Rule val composeRule = createComposeRule()

    @Test
    fun submitDisabledWhenEmpty() {
        composeRule.setContent {
            MyTheme { LoginScreen(onSubmit = {}) }
        }
        composeRule.onNodeWithText("Sign in").assertIsNotEnabled()
    }
}

Native graphics mode matters for Compose — legacy Robolectric rendering misses layout edge cases that show up on devices.

Shadows and limitations

Robolectric replaces framework internals with @Implements shadow classes. Most public APIs work; some don't:

Works well Problematic
SharedPreferences, SQLite Real OpenGL / Camera HAL
Intents, Bundles OEM-specific extensions
AlarmManager, JobScheduler Some Bluetooth APIs
Resources, themes Hardware sensors

When a shadow is incomplete, @Config(shadows = [CustomShadow.class]) lets you override behavior for your test.

CI configuration

Robolectric downloads Android SDK jars on first run. On CI, cache ~/.gradle/caches and ~/.m2/repository/org/robolectric. Run with:

./gradlew testDebugUnitTest --no-daemon

No emulator required — this is why Robolectric belongs in every Android module's default test task.

Testing ViewModels and coroutines

Robolectric supports runTest for coroutine-based ViewModels without instrumented tests:

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class OrderViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private lateinit var viewModel: OrderViewModel
    private val fakeRepo = FakeOrderRepository()

    @Before
    fun setup() {
        viewModel = OrderViewModel(fakeRepo)
    }

    @Test
    fun loadOrder_success() = runTest {
        fakeRepo.enqueue(Order(id = "1", total = 99.99))
        viewModel.loadOrder("1")
        advanceUntilIdle()
        assertEquals(99.99, viewModel.uiState.value.total)
    }

    @Test
    fun loadOrder_error() = runTest {
        fakeRepo.enqueueError(IOException("Network error"))
        viewModel.loadOrder("1")
        advanceUntilIdle()
        assertTrue(viewModel.uiState.value is OrderUiState.Error)
    }
}

MainDispatcherRule replaces Dispatchers.Main with a test dispatcher — essential for ViewModel tests that use viewModelScope.

SavedStateHandle testing

Process death scenarios without instrumented tests:

@Test
fun restoresStateFromSavedStateHandle() {
    val savedState = SavedStateHandle(mapOf("query" to "kotlin"))
    val viewModel = SearchViewModel(savedState, fakeRepo)

    assertEquals("kotlin", viewModel.query.value)
}

Parallel execution and test sharding

Robolectric tests are JVM tests — run in parallel on CI:

// build.gradle.kts
tasks.withType<Test> {
    maxParallelForks = Runtime.getRuntime().availableProcessors() / 2
}

For large test suites (500+ Robolectric tests), shard across CI matrix jobs:

# GitHub Actions
strategy:
  matrix:
    shard: [0, 1, 2, 3]
steps:
  - run: ./gradlew testDebugUnitTest -Probolectric.tests.shard=${{ matrix.shard }}/4

Failure modes

Production checklist

Robolectric vs instrumented tests: decision guide

Scenario Robolectric Instrumented
ViewModel unit tests ✅ Fast JVM ❌ Slow emulator
Compose layout tests ✅ With native graphics ✅ Real rendering
Screenshot tests ✅ Roborazzi ✅ Paparazzi/on-device
Navigation integration ✅ JVM ✅ Real back stack
Hardware APIs (Bluetooth, NFC) ❌ Shadow incomplete ✅ Required
Performance benchmarks ❌ Not representative ✅ Required

Default to Robolectric for unit and layout tests. Reserve instrumented tests for hardware-dependent features and performance validation. Target ratio: 80% Robolectric, 20% instrumented.

Pin Robolectric SDK version to match your compileSdk — shadow implementations drift between versions and cause false greens in CI.

Resources

Frequently asked questions

When should I use Robolectric instead of instrumented tests?

Use Robolectric for logic that touches Android framework classes — SharedPreferences, Intent parsing, BroadcastReceivers, ViewModels with SavedStateHandle — when you don't need real GPU rendering or OEM-specific behavior. Keep instrumented tests for CameraX, Bluetooth, notifications on real devices, and end-to-end flows.

Does Robolectric work with Hilt?

Yes. Use @HiltAndroidTest with @RunWith(RobolectricTestRunner::class) and HiltRobolectricTestRunner, or the standard Hilt test setup with @Config to specify the application class. Replace production modules with @TestInstallIn modules for fakes.

Why do Robolectric tests behave differently across SDK levels?

Robolectric simulates API behavior per @Config(sdk = [...]). Permission models, notification channels, and background execution rules differ by API level. Pin the SDK in tests to match your minSdk or the specific API you're testing, and use @Config(sdk = [33]) consistently across the module.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →