Android Testing
Irbisa · cheatsheetSeptember 13, 2026

Android Testing

JUnit, MockK, Turbine, Robolectric, Espresso, Compose UI tests, Hilt rules, screenshot tests

Middle Developer16 itemscompressed for a skim
  1. 01

    A teammate puts every new test in src/androidTest because "it is an Android app" — what does that cost, and what actually belongs there?

    Easy

    src/test runs on your machine's JVM in milliseconds while src/androidTest builds 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")
    …
  2. 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?

    Easy

    The android.jar on 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()
      }
    …
  3. 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?

    Easy

    A 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")
    …
  4. 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?

    Medium

    MockK was built for Kotlin, so final classes, objects, extension functions and suspend are 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 {
    …
  5. 05

    A test calls vm.state.toList() on a StateFlow and the suite hangs until CI kills it — what does Turbine do differently?

    Medium

    A StateFlow never 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()
    …
  6. 06

    Switching StandardTestDispatcher to UnconfinedTestDispatcher turns a red test green with no production change — which one was telling the truth?

    Medium

    Both 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()
    }
    …
  7. 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?

    Medium

    Robolectric 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()
    …
  8. 08

    Your ViewModel takes a SavedStateHandle, reads a string from Context, and registers a lifecycle observer that never fires in the test — how do you build what the system usually builds?

    Medium

    Each 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"])
    …
  9. 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?

    Medium

    Espresso 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. 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?

    Medium

    Not your composables — it queries the semantics tree, and by default the merged one, where a Button has 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. 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?

    Medium

    Every 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. 12

    The team says "the ViewModel handles it" — how do you actually prove a half-filled form comes back after Android kills the process?

    Medium

    With 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. 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?

    Hard

    They 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. 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?

    Hard

    Stop 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. 15

    How do you test an upload that only runs once WorkManager decides its constraints are met, without waiting on the real scheduler?

    Hard

    work-testing swaps the scheduler for one you drive: a synchronous executor runs the work on the calling thread, and a TestDriver lets 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. 16

    Leadership wants 80% line coverage on every module — what do you tell them, and what would you gate the build on instead?

    Hard

    Line 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
    }
    …