Android Testing
JUnit, MockK, Turbine, Robolectric, Espresso, Compose UI tests, Hilt rules, screenshot tests
- 01
A teammate puts every new test in
src/androidTestbecause "it is an Android app" — what does that cost, and what actually belongs there?Easysrc/testruns on your machine's JVM in milliseconds whilesrc/androidTestbuilds a second APK, installs it on a device and runs there in seconds — so putting everything on a device buys fidelity you usually do not need and pays for it on every commit.// build.gradle.kts — two source sets, two dependency configurations dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("org.robolectric:robolectric:4.14.1") … - 02
A plain JUnit test fails with
java.lang.RuntimeException: Method d in android.util.Log not mocked— what is that stub, and what are your ways out?EasyThe
android.jaron the unit-test classpath carries signatures only: every method body is `throw new RuntimeException("...// Fails on the JVM: pure logic reaches for the framework in the middle class DeepLinkParser { fun orderId(link: String): Long? { Log.d("DeepLink", "parsing $link") // RuntimeException: not mocked return Uri.parse(link).getQueryParameter("order")?.toLongOrNull() } … - 03
A CI run fails with
expected:<false> but was:<true>and nobody can tell what broke — what is wrong with how that test was written?EasyA boolean assertion throws away both values, so the failure can only say which line went red, not what the code did — a test's real output is its failure message, and that is the thing you design.
// ❌ Two acts, a boolean assertion, a name that describes the method class CartTest { @Test fun testCart() { val cart = Cart() cart.add(Item("apple", cents = 100)) cart.applyCoupon("HALF") … - 04
You need to stub a final Kotlin repository whose methods are
suspend— how does MockK handle that, and when is a hand-written fake the better answer?MediumMockK was built for Kotlin, so final classes,
objects, extension functions andsuspendare ordinary cases rather than the plugin-and-workaround territory they used to be.class OrderRepo(private val api: Api, private val dao: OrderDao) { // final, as Kotlin is suspend fun load(id: Long): Order = dao.byId(id) ?: api.fetch(id).also { dao.insert(it) } suspend fun archive(order: Order) { dao.insert(order.copy(archived = true)) } } class ArchiveUseCaseTest { … - 05
A test calls
vm.state.toList()on a StateFlow and the suite hangs until CI kills it — what does Turbine do differently?MediumA
StateFlownever completes, so any terminal operator that waits for completion waits forever; Turbine collects the flow in a background coroutine and hands you one event at a time, with a timeout instead of a hang.class SearchViewModelTest { @get:Rule val mainRule = MainDispatcherRule() // StandardTestDispatcher on Main // ❌ a StateFlow never completes, so this waits for an event that cannot happen @Test fun hangsForever() = runTest { val states = SearchViewModel(FakeRepo()).state.toList() … - 06
Switching
StandardTestDispatchertoUnconfinedTestDispatcherturns a red test green with no production change — which one was telling the truth?MediumBoth are honest about different things — Standard queues every coroutine until you advance the scheduler, Unconfined starts each one eagerly on the calling thread — so the red result was the accurate one, and the green test now asserts a moment the real Main dispatcher never produces.
class MainDispatcherRule( val dispatcher: TestDispatcher = StandardTestDispatcher(), ) : TestWatcher() { override fun starting(d: Description) = Dispatchers.setMain(dispatcher) override fun finished(d: Description) = Dispatchers.resetMain() } … - 07
A colleague calls the Robolectric suite "instrumented tests that just run fast" — what is Robolectric actually substituting, and where does that substitution mislead you?
MediumRobolectric loads the real Android framework bytecode into your JVM and rewrites selected methods to route into shadow objects, so it is the framework's Java with the native layer faked — not an emulator, and not your device.
@RunWith(AndroidJUnit4::class) // Robolectric, because this file lives in src/test @Config(sdk = [34]) // one level: each extra one is another full run class ReceiptScreenTest { private val context: Context = ApplicationProvider.getApplicationContext() … - 08
Your ViewModel takes a
SavedStateHandle, reads a string fromContext, and registers a lifecycle observer that never fires in the test — how do you build what the system usually builds?MediumEach of those has a public constructor or a testing artifact —
SavedStateHandle(mapOf(...)),TestLifecycleOwner,ApplicationProvider— and the observer stays silent because in production the Activity drives the lifecycle and in a unit test nothing does until you do.class OrderViewModel( savedState: SavedStateHandle, strings: StringProvider, // an interface, not a Context private val repo: OrderRepository, ) : ViewModel(), DefaultLifecycleObserver { val orderId: Long = checkNotNull(savedState["orderId"]) … - 09
An Espresso test asserts on data the screen loads over the network and fails every second run — what does Espresso wait for on its own, and what does it not?
MediumEspresso waits for the main thread's message queue to drain and for whatever you registered as an
IdlingResource; work on any other thread is invisible to it.@RunWith(AndroidJUnit4::class) class SearchScreenTest { @get:Rule val rule = ActivityScenarioRule(SearchActivity::class.java) @Before fun register() { IdlingRegistry.getInstance().register(AppIdling.network) … - 10
Your Compose test fails with "no node matched" on onNodeWithTag("price") although the price is plainly on screen — what does the test actually see?
MediumNot your composables — it queries the semantics tree, and by default the merged one, where a
Buttonhas swallowed the child that carried the tag.@Composable fun OrderRow(order: Order, onOpen: () -> Unit) { // Button merges its subtree into ONE semantics node Button(onClick = onOpen, modifier = Modifier.testTag("order-${order.id}")) { Column { Text(order.title) … - 11
A Compose test hangs on its first assertion while a CircularProgressIndicator is on screen — why, and how do you move it forward without Thread.sleep?
MediumEvery finder and assertion first waits for the app to be idle, and an indefinite animation is never idle — the indicator keeps requesting frames until the test times out.
class OrdersScreenTest { @get:Rule val rule = createComposeRule() private val repo = FakeOrderRepo() // ❌ hangs: the indicator animates forever, so the implicit idle wait never returns @Test fun showsContent_hangs() { … - 12
The team says "the ViewModel handles it" — how do you actually prove a half-filled form comes back after Android kills the process?
MediumWith two different tests, because a ViewModel does not survive process death — only what went into the saved-state bundle comes back.
// 1) The Compose half — did rememberSaveable actually restore? class FilterPanelTest { @get:Rule val composeRule = createComposeRule() @Test fun unreadFilterSurvivesRecreation() { val restorationTester = StateRestorationTester(composeRule) … - 13
You want screenshot tests but not an emulator in every PR — how do Paparazzi, Roborazzi and on-device captures differ, and what keeps a golden stable?
HardThey differ in what renders the pixels — layoutlib on the JVM, Robolectric's native graphics on the JVM, or a real device — and every stability decision follows from that choice.
// Paparazzi: layoutlib on the JVM, the device is a data class class OrderCardPaparazziTest { @get:Rule val paparazzi = Paparazzi( deviceConfig = DeviceConfig.PIXEL_5.copy(fontScale = 1.0f, locale = "en-rGB"), renderingMode = SessionParams.RenderingMode.SHRINK, // fit content, not the device ) … - 14
One CI run in twenty-five fails on a different instrumented test each time — how do you attribute the flake and get a failure you can reproduce?
HardStop treating it as one bug: measure per-test failure rates first, because "the suite is 4% flaky" is usually four causes with four different fixes.
// build.gradle.kts — the environment stops being a variable android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["clearPackageData"] = "true" // wipe data per test } … - 15
How do you test an upload that only runs once WorkManager decides its constraints are met, without waiting on the real scheduler?
Hardwork-testingswaps the scheduler for one you drive: a synchronous executor runs the work on the calling thread, and aTestDriverlets you declare that constraints, initial delays and periods have been met.@RunWith(AndroidJUnit4::class) class UploadWorkTest { private val context: Context = ApplicationProvider.getApplicationContext() @Before fun setUp() { val config = Configuration.Builder() … - 16
Leadership wants 80% line coverage on every module — what do you tell them, and what would you gate the build on instead?
HardLine coverage records which lines ran, not which behaviours are checked, so a suite that asserts nothing can hit 80% — gate on the coverage of the lines a change touches, and prove the assertions are real with mutation testing.
// The unit under test: one boundary, one off-by-one waiting to happen class Basket(private val items: List<Item>) { fun shippingCost(): Long = if (items.sumOf { it.cents } > FREE_SHIPPING_CENTS) 0L else FLAT_RATE_CENTS } …