Unit Testing with JUnit, Mockito, MockK and Robolectric

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Android code is tested in two very different places: fast, plain-JVM tests under src/test/ and slower tests that need a real or simulated Android framework under src/androidTest/. This page covers the JVM side in depth — JUnit, test doubles, Mockito, MockK, coroutine and Flow testing, and Robolectric, which runs Android-framework-dependent code on the JVM without a device — using TrailMate's TrailListViewModel, OfflineFirstTrailRepository, FakeTrailRepository, TrailDao and TrailListFragment throughout. Device-only UI testing with Espresso, Compose Testing and UI Automator is covered on Instrumented and UI Testing (Espresso, Compose Testing, UI Automator); static analysis and profiling are covered on Debugging, Code Quality and Performance. The Kotlin language features exercised in test code — coroutines, Flow, StateFlow — are explained in Coroutines Basics and Flows and are not re-taught here; general Kotlin test idioms (assertions, @Test, test naming) are covered on Kotlin Testing. TrailMate’s architecture (TrailListViewModel, GetTrailsUseCase, OfflineFirstTrailRepository, TrailRepository) is introduced on Recommended App Architecture; SavedStateHandle and ViewModel scoping are covered in full on ViewModel, Lifecycle-Aware Components, Coroutines and Flow. The version baseline used below is JUnit 4.13.2, JUnit Jupiter 6.1.3 with de.mannodermaus.android-junit5 2.0.1, Robolectric 4.17 (SDK 37), Mockito 5.24.0, mockito-kotlin 6.4.0, MockK 1.14.11, Turbine 1.2.1, kotlinx-coroutines-test 1.11.0, and Truth 1.4.5.

The Test Pyramid on Android

The test pyramid is a way of allocating testing effort across tiers that trade off speed, isolation and confidence. Cheap, fast, narrowly scoped tests should vastly outnumber expensive, slow, broadly scoped ones, because a suite dominated by slow tests is a suite nobody runs often:

A four-tier pyramid diagram labelled TrailMate’s test pyramid. From bottom to top: Static Analysis (Android Lint, ktlint, detekt) runs on every build against the whole codebase with no test code executing at all; Unit Tests under src/test/ (JUnit 4/5, Mockito, mockito-kotlin, MockK, Turbine) run on a plain JVM in milliseconds and exercise TrailListViewModel and FakeTrailRepository; Robolectric Tests under src/test/ (ActivityScenario, FragmentScenario, Compose, Room) run on a JVM with a simulated Android framework in seconds, with no emulator needed; Instrumented / UI tests (Espresso, Compose UI testing, UI Automator) run on a real device or emulator and are covered on a separate page. A vertical arrow on the right is labelled many, fast at the bottom and few, slow at the top. A caption notes that this page covers the bottom two tiers and each tier is cheaper and faster to run than the one above it, and should have more tests than it.

Android’s own testing guidance calls the bottom two tiers local tests (they run entirely on the JVM that runs Gradle, whether that is a developer’s laptop or a CI runner) and the top tier instrumented tests (they run inside an Android process, on a device or emulator, driven by adb). Local tests are one to two orders of magnitude faster to start and run because there is no APK to install and no device to boot; the trade-off is that plain local tests (the second tier) cannot touch anything that only exists in the real Android framework — Context, Looper, resources, SharedPreferences, a live View hierarchy — because the android.jar that JVM tests compile and run against contains only method stubs that throw RuntimeException("Stub!") if called. Robolectric (the third tier) removes that restriction by providing working, JVM-hosted implementations of the framework classes, at the cost of being slower and less faithful than a real device. This page’s structure follows the pyramid bottom-up: fakes and mocks and pure JVM tests first, Robolectric last.

test/ vs. androidTest/ and Test Dependencies

Every Gradle Android module has two parallel test source sets, chosen by where the class under test needs to run:

Source set Runs on Used for

src/test/

The JVM running Gradle/the IDE, using a special android.jar unless Robolectric substitutes real implementations.

TrailListViewModelTest, OfflineFirstTrailRepositoryTest, FakeTrailRepository, TrailDaoTest (with an in-memory Room database), and, with Robolectric, TrailListFragmentTest.

src/androidTest/

A real device or emulator, launched via adb, with the app itself installed alongside a test APK.

Espresso and Compose UI tests, TrailListScreenTest, anything exercising the real WindowManager, camera, location or other hardware-backed APIs.

src/test/ dependencies are declared with testImplementation; src/androidTest/ dependencies use androidTestImplementation. TrailMate’s module-level build.gradle.kts declares the unit-testing dependencies this page covers:

// feature/trails/build.gradle.kts
dependencies {
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.mockito:mockito-core:5.24.0")
    testImplementation("org.mockito.kotlin:mockito-kotlin:6.4.0")
    testImplementation("io.mockk:mockk:1.14.11")
    testImplementation("app.cash.turbine:turbine:1.2.1")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
    testImplementation("com.google.truth:truth:1.4.5")
    testImplementation("androidx.arch.core:core-testing:2.2.0")

    // Robolectric and the framework APIs it simulates
    testImplementation("org.robolectric:robolectric:4.17")
    testImplementation("androidx.test:core:1.7.0")
    testImplementation("androidx.test.ext:junit:1.3.0")
    testImplementation("androidx.fragment:fragment-testing:1.9.1")
    testImplementation(platform("androidx.compose:compose-bom:2026.09.00"))
    testImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")

    // Repository tests
    testImplementation("com.squareup.okhttp3:mockwebserver3:5.5.0")
}

See Android Developers — Test apps on Android. Mockito on the JVM test source set needs no extra runner because it operates purely through bytecode manipulation; it only becomes mockito-android (a separate artifact whose mock maker generates subclasses with ByteBuddy’s Android support, since the JVM’s instrumentation agent isn’t available on ART) when mocking classes from androidTestImplementation instrumented tests. This page never needs mockito-android because Robolectric tests are still src/test/, plain-JVM tests — Mockito’s default inline mock maker (described below) is sufficient there too.

JUnit 4, and Optional JUnit 5/6

Android’s default local-test runner is still JUnit 4, and Robolectric, androidx.test, and most third-party Android test libraries are built against it. A JUnit 4 test class is a plain class whose test methods are annotated @Test; @Before/@After run around each test, @BeforeClass/@AfterClass run once per class:

// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/GetTrailsUseCaseTest.kt
class GetTrailsUseCaseTest {

    private lateinit var repository: FakeTrailRepository
    private lateinit var useCase: GetTrailsUseCase

    @Before
    fun setUp() {
        repository = FakeTrailRepository()
        useCase = GetTrailsUseCase(repository)
    }

    @Test
    fun `returns only favorite trails when onlyFavorites is true`() = runTest {
        repository.submitTrails(sampleTrails)

        val result = useCase(onlyFavorites = true).first()

        assertThat(result).containsExactly(sampleTrails[0])
    }
}

See Android Developers — Fundamentals of testing. JUnit 4 test classes on Android normally run under the default JUnit4 runner (implicit) or, for framework-touching tests, @RunWith(AndroidJUnit4::class) from androidx.test.ext.junit.runners, which dispatches to Robolectric automatically when the test is running under src/test/ with the Robolectric Gradle plugin applied, or to the on-device instrumentation runner under src/androidTest/.

JUnit 5 (Jupiter) is supported on Android only through the third-party de.mannodermaus.android-junit5 Gradle plugin, because AGP’s built-in unit-test task still expects a JUnit 4-shaped runner:

// feature/trails/build.gradle.kts
plugins {
    id("de.mannodermaus.android-junit5") version "2.0.1"
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.1.3")
}
// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/DifficultyTest.kt
class DifficultyTest {

    @ParameterizedTest
    @EnumSource(Difficulty::class)
    fun `every difficulty has a non-blank label`(difficulty: Difficulty) {
        assertThat(difficulty.label).isNotEmpty()
    }
}

JUnit 5’s parameterized tests, nested test classes (@Nested) and extension model (@ExtendWith) are genuinely nicer than JUnit 4’s @RunWith(Parameterized::class) and TestRule, but Robolectric’s official JUnit 5 support is younger and less battle-tested than its JUnit 4 support, and most Android sample code, androidx.test rules and library test-fixtures still assume JUnit 4. For TrailMate, plain unit tests with no framework dependency (model logic, mappers, use cases) are reasonable candidates for JUnit 5; anything using Robolectric or androidx.test rules stays on JUnit 4 for compatibility. See GitHub — android-junit5.

Fakes vs. Mocks: FakeTrailRepository

A test double is any object substituted for a real collaborator in a test. Two kinds cover almost every case in TrailMate:

Kind Behaviour

Fake

A real, working, simplified implementation of the same interface — for example an in-memory Map standing in for Room. It has actual behaviour and state, so it survives refactors to how a test calls it as long as the observable contract stays the same.

Mock

A generated stand-in, produced by a framework (Mockito, MockK), that records calls and returns canned answers configured by the test. It has no behaviour of its own beyond what is stubbed, so it is well suited to asserting interactions ("was refresh() called exactly once?") rather than state.

TrailMate uses a hand-written fake, FakeTrailRepository, for most TrailListViewModel tests, because the ViewModel cares about the shape and evolution of Flow<List<Trail>> over time — something a fake’s real MutableStateFlow expresses naturally and a mock’s stubbed single return value does not:

// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/FakeTrailRepository.kt
class FakeTrailRepository : TrailRepository {

    private val trailsFlow = MutableStateFlow<List<Trail>>(emptyList())
    private var shouldFailRefresh = false

    override fun observeTrails(onlyFavorites: Boolean): Flow<List<Trail>> =
        trailsFlow.map { trails -> if (onlyFavorites) trails.filter { it.isFavorite } else trails }

    override suspend fun refresh(region: String?) {
        if (shouldFailRefresh) throw IOException("network unavailable")
        // In a real fake this would merge fetched trails into trailsFlow.
    }

    override suspend fun setFavorite(trailId: String, isFavorite: Boolean) {
        trailsFlow.update { trails ->
            trails.map { if (it.id == trailId) it.copy(isFavorite = isFavorite) else it }
        }
    }

    fun submitTrails(trails: List<Trail>) {
        trailsFlow.value = trails
    }

    fun failNextRefresh() {
        shouldFailRefresh = true
    }
}

See Android Developers — Test doubles. OfflineFirstTrailRepositoryTest itself, one layer down, tests the real repository, so it fakes or mocks TrailDao and TrailApi instead — TrailDao is small enough to fake with an in-memory list, while TrailApi (a Retrofit interface with a single suspend method per endpoint) is easiest to mock, since the test only cares that the repository called it with the right arguments and handled its result or its exception.

Mockito and mockito-kotlin

Mockito generates a mock at runtime for any interface or open class and lets a test stub its methods and verify how it was called. Plain Mockito’s Java-oriented API (Mockito.mock(Foo::class.java), Mockito.`when(foo.bar()).thenReturn(…​)) is awkward in Kotlin — `when is a keyword, and Mockito’s Java API cannot stub methods returning platform types cleanly. mockito-kotlin wraps the same engine in idiomatic Kotlin extension functions:

// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/OfflineFirstTrailRepositoryTest.kt
class OfflineFirstTrailRepositoryTest {

    private val trailApi: TrailApi = mock()
    private val trailDao: TrailDao = mock()
    private lateinit var repository: OfflineFirstTrailRepository

    @Before
    fun setUp() {
        repository = OfflineFirstTrailRepository(trailDao = trailDao, trailApi = trailApi, ioDispatcher = Dispatchers.Unconfined)
    }

    @Test
    fun `refresh fetches from the API and writes to the DAO`() = runTest {
        whenever(trailApi.getTrails(page = 1, region = null)).thenReturn(listOf(sampleDto))

        repository.refresh(region = null)

        verify(trailDao).upsertAll(listOf(sampleDto.toEntity()))
    }

    @Test
    fun `refresh leaves the cache untouched when the API call fails`() = runTest {
        doThrow(IOException("timeout")).whenever(trailApi).getTrails(page = 1, region = null)

        assertThrows(IOException::class.java) { runBlocking { repository.refresh(region = null) } }

        verify(trailDao, never()).upsertAll(any())
    }
}

mock() creates the mock; whenever(…​).thenReturn(…​) is mockito-kotlin’s Kotlin-friendly replacement for `Mockito.when. doReturn(…​).whenever(mock).method() (and doThrow/doAnswer) exist as an alternative stubbing order for the rare case where the real whenever(mock.method()) call itself would throw — for example stubbing a spy() of a real object whose method has side effects, or a final method that a stricter mock maker would otherwise reject before the stub is even installed. Because refresh is a suspend function, both whenever and doThrow work on it unchanged: Mockito treats a Kotlin suspend function as an ordinary function taking a hidden Continuation parameter, and mockito-kotlin’s stubbing functions are themselves marked `suspend where needed so the call composes naturally inside runTest.

Verification, Captors and Ordering

verify(mock) asserts a call happened; verify(mock, times(n)), never(), atLeastOnce() and atMost(n) constrain how many times. An ArgumentCaptor (wrapped by mockito-kotlin’s `argumentCaptor<T>()) captures the actual argument a mock was called with, for assertions the equality-based verify(mock).method(exactValue) cannot express:

@Test
fun `refresh upserts entities built from the DTO's own id`() = runTest {
    whenever(trailApi.getTrails(page = 1, region = null)).thenReturn(listOf(sampleDto))
    val captor = argumentCaptor<List<TrailEntity>>()

    repository.refresh(region = null)

    verify(trailDao).upsertAll(captor.capture())
    assertThat(captor.firstValue.single().id).isEqualTo(sampleDto.id)
}

inOrder(mock1, mock2) asserts that calls happened in a specific relative sequence across one or more mocks — useful for asserting that TrailSyncWorker clears a stale-data flag before it upserts fresh rows, not just that both calls happened:

@Test
fun `sync clears the stale flag before writing fresh trails`() = runTest {
    worker.doWork()

    val order = inOrder(userPreferencesRepository, trailDao)
    order.verify(userPreferencesRepository).clearStaleFlag()
    order.verify(trailDao).upsertAll(any())
}

The Inline Mock Maker and mockito-android

Classic Mockito could only mock interfaces and non-final classes, because it generated a subclass proxy at runtime. Since Mockito 5, the inline mock maker (mockito-core’s default since 5.0, previously the opt-in `mockito-inline artifact) is used automatically: it instruments bytecode via a Java agent instead of subclassing, so it can mock final classes and methods, and even object`s and static methods when combined with `mockito-kotlin’s helpers — Kotlin classes are `final by default, so this matters constantly on Android. mockito-android is a separate artifact needed only for androidTestImplementation (instrumented, on-device) tests, because Mockito’s Java-agent-based inline mock maker can’t attach inside the Android Runtime, so mockito-android substitutes a ByteBuddy-based subclassing mock maker (which, unlike the inline one, cannot mock final classes); none of the tests on this page need it, since Robolectric tests still run as plain JVM tests.

MockK

MockK is a mocking library written for Kotlin from the start, and is the more natural fit for suspend functions, object`s and extension functions than retrofitting Mockito. TrailMate’s tests may use either consistently within a module; mixing both in the same test class is possible but adds noise for no benefit. The MockK equivalent of the `OfflineFirstTrailRepositoryTest above:

// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/OfflineFirstTrailRepositoryMockkTest.kt
class OfflineFirstTrailRepositoryMockkTest {

    private val trailApi = mockk<TrailApi>()
    private val trailDao = mockk<TrailDao>(relaxed = true)
    private lateinit var repository: OfflineFirstTrailRepository

    @Before
    fun setUp() {
        repository = OfflineFirstTrailRepository(trailDao = trailDao, trailApi = trailApi, ioDispatcher = Dispatchers.Unconfined)
    }

    @Test
    fun `refresh fetches from the API and writes to the DAO`() = runTest {
        coEvery { trailApi.getTrails(page = 1, region = null) } returns listOf(sampleDto)

        repository.refresh(region = null)

        coVerify { trailDao.upsertAll(listOf(sampleDto.toEntity())) }
    }
}

coEvery/coVerify are MockK’s suspend-aware equivalents of every/verify; plain every/verify only work on non-suspend functions. A relaxed mock (mockk<T>(relaxed = true), or relaxUnitFun = true for a mock whose Unit-returning functions alone should auto-answer) returns sensible defaults (empty collections, zero, false, another relaxed mock) for every unstubbed call instead of throwing, which trims boilerplate for collaborators like trailDao where a test only cares about one or two specific calls. mockkStatic and mockkObject extend mocking to Kotlin object`s and top-level/extension functions that neither Mockito nor a constructor-injected fake can reach — for example stubbing a top-level `Clock.now()-style function used directly inside RecordHikeService:

@Test
fun `records the hike using the injected clock`() {
    mockkStatic(::currentTimeMillisProvider)
    every { currentTimeMillisProvider() } returns FIXED_TIME_MS

    // ... exercise code that calls currentTimeMillisProvider() internally

    unmockkStatic(::currentTimeMillisProvider)
}

Reaching for mockkStatic is a signal worth noticing: a dependency that can only be swapped by intercepting a static/top-level call was not injected in the first place, and a constructor parameter or Hilt binding (see Dependency Injection with Hilt, Dagger and Koin) is usually the better long-term fix. See MockK.

Testing Coroutines and Flow

TrailListViewModel and OfflineFirstTrailRepository are built on coroutines and Flow (Coroutines Basics, Flows), so testing them means controlling virtual time rather than sleeping the test thread. kotlinx-coroutines-test’s `runTest { } builder runs its body in a TestScope backed by a TestCoroutineScheduler, skips delay() calls in virtual time for every coroutine that runs on a TestDispatcher sharing that scheduler, and fails the test if a launched child coroutine is still active when the block ends — catching the leaked-coroutine bugs a plain runBlocking test would silently hide. It does not replace Dispatchers.Default or Dispatchers.IO: work that code under test moves there with withContext(Dispatchers.IO) runs on real threads, and its delays are real. That is why TrailMate’s repositories and use cases take a CoroutineDispatcher constructor parameter, which a test sets to a StandardTestDispatcher(testScheduler):

@Test
fun `refresh failure surfaces without a real delay`() = runTest {
    val repository = OfflineFirstTrailRepository(
        trailDao = fakeTrailDao,
        trailApi = failingTrailApi,
        ioDispatcher = StandardTestDispatcher(testScheduler), // injected, not Dispatchers.IO
    )

    val result = runCatching { repository.refresh(region = null) }

    assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java)
}

runTest defaults to a StandardTestDispatcher: coroutines it launches are queued, not run, and runTest does not idle between statements. Queued work runs only when the test body suspends (for example on join(), awaitItem() or a delay()), when it calls advanceUntilIdle(), advanceTimeBy(…​) or runCurrent() explicitly, or when the body finishes. This matches how a real dispatcher behaves — work is scheduled, not run inline — and is the right default for asserting intermediate states (a loading state before a result arrives). An UnconfinedTestDispatcher instead runs a launched coroutine eagerly, up to its first suspension point, before the launching call even returns; it trades that realism for less boilerplate when a test only cares about the final state and would otherwise need an extra advanceUntilIdle() after every viewModel.load() call.

TrailListViewModel launches work in viewModelScope, which is hard-wired to Dispatchers.Main. A test needs Dispatchers.setMain(…​) before creating the ViewModel and Dispatchers.resetMain() afterwards, wrapped in a reusable TestRule so every ViewModel test gets it automatically and cannot forget to reset it:

// core/testing/src/main/kotlin/com/example/trailmate/testing/MainDispatcherRule.kt
class MainDispatcherRule(
    private val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

Testing TrailListViewModel `StateFlow`s with Turbine

Asserting against a StateFlow’s `.value directly only checks the state at one instant; it misses whether intermediate states (a loading state, then an error, then data) were ever emitted at all. Turbine turns a Flow into a sequential, suspend-based test API instead:

// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/TrailListViewModelTest.kt
class TrailListViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = FakeTrailRepository()
    private lateinit var viewModel: TrailListViewModel

    @Before
    fun setUp() {
        viewModel = TrailListViewModel(GetTrailsUseCase(repository), repository)
    }

    @Test
    fun `emits Loading then Success once trails arrive`() = runTest {
        viewModel.uiState.test {
            assertThat(awaitItem()).isEqualTo(TrailListUiState.Loading)

            repository.submitTrails(sampleTrails)

            assertThat(awaitItem()).isEqualTo(TrailListUiState.Success(sampleTrails))
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `toggling a favorite updates the exposed state`() = runTest {
        backgroundScope.launch { viewModel.uiState.collect() }
        repository.submitTrails(sampleTrails)
        advanceUntilIdle() // let the collector and the stateIn upstream run

        viewModel.onToggleFavorite(sampleTrails[0].id)
        advanceUntilIdle() // let the toggle queued on viewModelScope run

        val state = viewModel.uiState.value as TrailListUiState.Success
        assertThat(state.trails.first { it.id == sampleTrails[0].id }.isFavorite).isTrue()
    }
}

.test { } opens a ReceiveTurbineChannel around the Flow; awaitItem() suspends until the next distinct emission and fails with a clear diff-style message if none arrives before Turbine’s default timeout, which is far more diagnosable than a plain first() call that hangs forever. TrailListViewModel.uiState is typically built with stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue) (Recommended App Architecture), which only starts collecting its upstream Flow once someone subscribes — exactly what .test { } and backgroundScope.launch { …​ .collect() } both do. backgroundScope, provided by runTest, is a CoroutineScope that Turbine and hand-written collect() calls can launch into without the test itself waiting for that collection to finish; it is automatically cancelled when the test body returns, so a StateFlow collector that would otherwise never complete does not hang or leak. See GitHub — Turbine.

Testing SavedStateHandle

TrailDetailViewModel reads its trailId navigation argument from a SavedStateHandle (ViewModel, Lifecycle-Aware Components, Coroutines and Flow). androidx.lifecycle:lifecycle-viewmodel-savedstate (transitively pulled in by lifecycle-viewmodel-ktx) lets a test construct a real, working SavedStateHandle directly, with no framework or mock involved:

@Test
fun `loads the trail identified by the trailId argument`() = runTest {
    val savedStateHandle = SavedStateHandle(mapOf("trailId" to "trail-42"))
    val viewModel = TrailDetailViewModel(savedStateHandle, repository)

    repository.submitTrails(listOf(sampleTrails[1].copy(id = "trail-42")))

    viewModel.uiState.test {
        assertThat((awaitItem() as TrailDetailUiState.Success).trail.id).isEqualTo("trail-42")
    }
}

A SavedStateHandle built this way behaves like the real thing for get/set/getStateFlow, which is why it does not need Robolectric or a mock — it is a small, self-contained, already-fake-friendly class, unlike Context or a View.

Robolectric

Robolectric provides shadow implementations of framework classes — Activity, Context, View, SharedPreferences, Looper, and hundreds more — that run inside the same JVM process as the test, giving src/test/ tests access to real (if simplified) framework behaviour without booting an emulator. This is the pyramid’s second tier: still much faster than an instrumented test, at the cost of being slower than a plain unit test and occasionally diverging from real-device behaviour on framework edge cases.

Setup and @Config

// feature/trails/build.gradle.kts
android {
    testOptions {
        unitTests {
            isIncludeAndroidResources = true // Robolectric needs access to res/ and the manifest
        }
    }
}
// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/TrailListFragmentTest.kt
@RunWith(AndroidJUnit4::class)
@Config(sdk = [37], application = TrailMateTestApplication::class)
class TrailListFragmentTest {
    // ...
}

isIncludeAndroidResources = true is required whenever a test needs res/ (layouts, strings, themes) rather than only framework classes. @Config overrides Robolectric’s defaults per test class or method: sdk pins which simulated framework revision runs (TrailMate targets sdk = 37 to match compileSdk; Robolectric 4.17 is the first release with SDK 37 support), and application substitutes a lightweight test Application subclass in place of TrailMateApplication when Hilt or other startup work should be skipped. See Robolectric and Android Developers — Robolectric.

ActivityScenario and FragmentScenario on the JVM

ActivityScenario and FragmentScenario (from androidx.test.core and androidx.fragment:fragment-testing) drive an activity or fragment through lifecycle states programmatically. They work identically under Robolectric (JVM, src/test/) and on a real device (src/androidTest/) — the same test code, only the runtime underneath differs — which is why TrailMate keeps TrailListFragmentTest under src/test/ running via Robolectric instead of duplicating it as a slower instrumented test:

@RunWith(AndroidJUnit4::class)
@Config(sdk = [37])
class TrailListFragmentTest {

    @Test
    fun `shows one row per trail once the ViewModel emits Success`() {
        val fragmentArgs = bundleOf()
        launchFragmentInContainer<TrailListFragment>(fragmentArgs, themeResId = R.style.Theme_TrailMate) {
            TrailListFragment().also { fragment ->
                fragment.viewModelFactory = FakeViewModelFactory(sampleUiState = TrailListUiState.Success(sampleTrails))
            }
        }.onFragment { fragment ->
            val recyclerView = fragment.requireView().findViewById<RecyclerView>(R.id.trailRecyclerView)
            assertThat(recyclerView.adapter?.itemCount).isEqualTo(sampleTrails.size)
        }
    }
}

launchFragmentInContainer (the FragmentScenario entry point) inflates the fragment inside a bare host activity, runs it up to RESUMED, and hands back a FragmentScenario whose onFragment { } block runs on the main looper with direct access to the live fragment and its view hierarchy — the same TrailListFragment/TrailAdapter/ItemTrailBinding structure covered on RecyclerView and Lists. ActivityScenario.launch(MainActivity::class.java) is the equivalent entry point for a whole activity, useful for TrailDetailFragment’s Safe Args navigation flow or for exercising `onSaveInstanceState/recreation with scenario.recreate().

Compose Tests Under Robolectric

Compose UI tests (createComposeRule(), finders, performClick(), assertions) normally run as instrumented tests, but Robolectric can host them too via the androidx.compose.ui:ui-test-junit4 artifact’s RobolectricComposeRule-style support (Robolectric’s native graphics mode, described below, plus AndroidX Test’s ComposeTestRule), letting TrailListScreen-level Compose tests run on the JVM tier when full pixel-accurate rendering is not required:

@RunWith(AndroidJUnit4::class)
@Config(sdk = [37])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TrailListScreenRobolectricTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun `tapping a trail row invokes onTrailClick with its id`() {
        var clickedId: String? = null
        composeRule.setContent {
            TrailListScreen(uiState = TrailListUiState.Success(sampleTrails), onTrailClick = { clickedId = it })
        }

        composeRule.onNodeWithText(sampleTrails[0].name).performClick()

        assertThat(clickedId).isEqualTo(sampleTrails[0].id)
    }
}

The full Compose testing API (finders, actions, assertions, testTag, mainClock) is covered in depth on Instrumented and UI Testing; this section only notes that the same API also runs under Robolectric.

Shadows

A shadow is Robolectric’s substitute implementation for one framework class. Most shadows are transparent — ShadowLooper, ShadowSharedPreferences and the rest apply automatically — but a test can reach into a shadow directly for control a real device would not offer, such as advancing a background Looper synchronously instead of waiting for it:

// import java.time.Duration
@Test
fun `delayed background handler callback runs once the looper's clock advances`() {
    val handler = Handler(HandlerThread("sync").apply { start() }.looper)
    var didRun = false

    handler.postDelayed({ didRun = true }, 1_000)
    shadowOf(handler.looper).idle()          // runs only tasks already due: the delayed one is still pending
    assertThat(didRun).isFalse()

    shadowOf(handler.looper).idleFor(Duration.ofSeconds(1)) // advances the clock, then runs what became due

    assertThat(didRun).isTrue()
}

In Robolectric’s default PAUSED looper mode, idle() executes only the tasks scheduled at or before the looper’s current time, so a postDelayed callback stays queued; idleFor(…​) first advances the clock by the given duration and then executes everything that has become due. shadowOf(…​) (from org.robolectric.Shadows) is the standard entry point for looking up the shadow behind any framework object. Custom shadows (@Implements/@Implementation) exist for stubbing out TrailMate’s own GLTextureView-style custom views or a vendor SDK class Robolectric does not already know how to simulate, but are needed rarely enough that most Android modules never write one.

Room Tests

TrailDaoTest runs Room itself, not a fake, against a real (if in-memory) SQLite database — the DAO’s SQL is exactly what ships in the app, so this test would catch a broken @Query a fake never could:

@RunWith(AndroidJUnit4::class)
@Config(sdk = [37])
class TrailDaoTest {

    private lateinit var database: TrailMateDatabase
    private lateinit var trailDao: TrailDao

    @Before
    fun setUp() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            TrailMateDatabase::class.java
        ).allowMainThreadQueries().build()
        trailDao = database.trailDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun `favoriteTrails only returns trails marked as favorite`() = runTest {
        trailDao.upsertAll(listOf(sampleEntity.copy(isFavorite = true), sampleEntity.copy(id = "2", isFavorite = false)))

        val favorites = trailDao.favoriteTrails().first()

        assertThat(favorites).hasSize(1)
    }
}

Room.inMemoryDatabaseBuilder needs a real Context, which is exactly what Robolectric’s ApplicationProvider.getApplicationContext() supplies without a device. allowMainThreadQueries() is only ever appropriate in tests — production code always dispatches Room access off the main thread, as covered on Room Database and Paging. See Android Developers — Test Room databases.

@GraphicsMode and Screenshot Tests

By default Robolectric’s LEGACY graphics mode does not actually rasterize pixels, which is enough for assertion on view properties and text but not for capturing an image. @GraphicsMode(GraphicsMode.Mode.NATIVE) switches a test class to Robolectric’s native graphics pipeline, which does rasterize, and is a prerequisite for any screenshot-style test running under Robolectric (as used above for the Compose test). TrailMate’s own screenshot-testing strategy — Compose Preview Screenshot Testing, which renders @Preview composables and diffs them against golden images — is covered on Instrumented and UI Testing, since it is driven by a separate Gradle task rather than by @Test methods on this tier.

Repository Tests with MockWebServer

OfflineFirstTrailRepository ultimately depends on TrailApi, a Retrofit interface (Networking with Retrofit, OkHttp and Ktor). Mocking TrailApi directly (as in the Mockito and MockK sections above) verifies the repository’s own logic in isolation, but never exercises the real Retrofit/OkHttp/JSON-parsing stack. mockwebserver3, from the same OkHttp project, runs a real local HTTP server the test controls, so a TrailApiTest can point a real Retrofit instance at it and assert on the resulting requests and parsed responses end-to-end:

// core/network/src/test/kotlin/com/example/trailmate/core/network/TrailApiTest.kt
class TrailApiTest {

    private val server = MockWebServer()
    private lateinit var trailApi: TrailApi

    @Before
    fun setUp() {
        server.start()
        val retrofit = Retrofit.Builder()
            .baseUrl(server.url("/v1/"))
            .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
            .build()
        trailApi = retrofit.create(TrailApi::class.java)
    }

    @After
    fun tearDown() {
        server.shutdown()
    }

    @Test
    fun `getTrails parses a paged trail list response`() = runTest {
        server.enqueue(MockResponse.Builder()
            .code(200)
            .body("""{"trails":[{"id":"1","name":"Ridge Loop","region":"north","lengthKm":8.2,"difficulty":"MODERATE"}]}""")
            .build())

        val trails = trailApi.getTrails(page = 1, region = null)

        val request = server.takeRequest()
        assertThat(request.url.encodedPath).isEqualTo("/v1/trails")
        assertThat(trails.single().name).isEqualTo("Ridge Loop")
    }

    @Test
    fun `getTrails throws HttpException on a 500 response`() = runTest {
        server.enqueue(MockResponse.Builder().code(500).build())

        assertThrows(HttpException::class.java) { runBlocking { trailApi.getTrails(page = 1, region = null) } }
    }
}

server.takeRequest() hands back the actual RecordedRequest the server received, so a test can assert on the path, headers, method and body sent — catching a wrong query-parameter name or a missing Content-Type that a mocked TrailApi would never surface. MockResponse.Builder queues one canned response per enqueue(…​) call (OkHttp 5’s builder API; mockwebserver3 is the mockwebserver artifact rewritten in Kotlin for OkHttp 4+, with a small API shift from the older MockResponse().setResponseCode(…​) mutation style). See GitHub — OkHttp MockWebServer.

JaCoCo Coverage

JaCoCo instruments compiled bytecode to record which lines and branches a test run actually exercised, then renders that as an HTML/XML report. AGP wires JaCoCo into the testDebugUnitTest task automatically once enabled:

// feature/trails/build.gradle.kts
android {
    buildTypes {
        debug {
            enableUnitTestCoverage = true
        }
    }
}

// Optional custom report task (import org.gradle.testing.jacoco.tasks.JacocoReport);
// ./gradlew :feature:trails:createDebugUnitTestCoverageReport already produces the same HTML/XML report.
tasks.register<JacocoReport>("jacocoTestReport") {
    dependsOn("testDebugUnitTest")
    reports {
        xml.required.set(true)
        html.required.set(true)
    }
    val fileFilter = listOf("**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*")
    val debugTree = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) {
        exclude(fileFilter)
    }
    classDirectories.setFrom(debugTree)
    sourceDirectories.setFrom(files("src/main/kotlin"))
    executionData.setFrom(
        layout.buildDirectory.file("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
    )
}

With enableUnitTestCoverage = true, AGP registers create<Variant>UnitTestCoverageReport (here createDebugUnitTestCoverageReport), which writes its report to build/reports/coverage/test/debug/, and it records the unit-test execution data at build/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec — the file the custom task above, or a jacococli.jar report step in CI, reads. The directory arguments use layout.buildDirectory.dir(…​)/.file(…​) providers rather than a string built from buildDirectory, so the paths resolve against the real build directory (a literal ${…​} inside a Kotlin string would not be expanded and would leave the report empty).

Only the two JVM tiers of the pyramid — plain unit tests and Robolectric tests, both run by testDebugUnitTest — contribute to this report by default; instrumented tests write their own .ec execution data under connectedDebugAndroidTest and need a separate merge step to combine both into one coverage number, which is why coverage percentages quoted from CI are usually the unit-test figure alone unless the pipeline explicitly merges both. A coverage percentage is a proxy for "this code ran during a test", not for "this code is correct" — a test with no assertions still counts as full coverage of the lines it executes — so JaCoCo numbers are best used to find untested code (a class or branch at 0%) rather than as a quality target to chase past a reasonable threshold. irurueta-android-glutils’s own CI pipeline (.github/workflows/main.yml`) runs ./gradlew clean test connectedAndroidTest and converts the resulting JaCoCo execution data with a jacococli.jar from a JaCoCo 0.8.13 distribution it keeps committed in the repository (jacoco-0.8.13/) for its SonarQube scan, the same shape of pipeline TrailMate’s own CI would use — covered in full on CI/CD with GitHub Actions.