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:
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 |
|---|---|---|
|
The JVM running Gradle/the IDE, using a special |
|
|
A real device or emulator, launched via |
Espresso and Compose UI tests, |
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 |
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 |
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())
}
See Mockito and javadoc.io — mockito-kotlin.
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.