Skip to content
Commitwise
Menu
Android Development

Testing

Test an Android app at the right levels, with fast ViewModel tests using fakes, runTest, test dispatchers and Turbine for Flows, Compose UI tests that find elements by semantics, and a few instrumented tests on real storage and real devices, without sleeps or flakiness.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The checkout ViewModel has a bug that only shows when the coupon request fails after the cart has loaded. Finding it by hand means: install the app, add items, turn on airplane mode at the right moment, apply a coupon. Nobody does that before every release, so it ships. A test that does the same thing in 20 milliseconds, with a fake API that fails on cue, runs on every commit.

This tutorial covers testing Android code where most of the logic lives (ViewModels and repositories), then Compose UI tests, then what’s worth running on a device. The general ideas (what to test, test doubles, test structure) are in Unit testing.

New to some of these? Open what you need, or skip ahead.

Basics:What is a unit test?

A unit testUnit testA small, fast, automatic check that calls one piece of code with known input and compares the result with the expected value.Open in glossary → is a small piece of code that runs one part of your app, such as a function or a ViewModel, with known inputs, and checks the result. Local unit tests run on your computer’s JVM (in src/test), in milliseconds, without an emulator.

Basics:What is a test double?

A test doubleTest doubleA stand-in for a real dependency in a test, such as a fake payment gateway, so the test is fast, repeatable and safe.Open in glossary → stands in for a real dependency: a fake repository that keeps data in a list, or a fake API that fails when the test says so. Because the ViewModel receives its dependencies through its constructor, the test decides which ones it gets.

Part Not started

Testing ViewModels and coroutines

A ViewModel is the best place to test an Android app’s behavior: it’s plain Kotlin, it decides what the screen shows, and with fakes it runs without network or database. Two things need setting up because of coroutines:

  • viewModelScope uses the Main dispatcher, which doesn’t exist on your computer’s JVM. A small rule replaces it with a test dispatcher.
  • Tests run inside runTest, which runs coroutines on a virtual clock: delay(5_000) takes no real time.

kotlinx-coroutines-test provides runTest (virtual time, fails on uncaught exceptions and leaked coroutines after a timeout), StandardTestDispatcher (queues coroutines until the scheduler advances: explicit control) and UnconfinedTestDispatcher (runs eagerly: simpler, less control). Dispatchers.setMain swaps the Main dispatcher used by viewModelScope; share the scheduler between Main and any injected dispatchers so advanceUntilIdle() drives everything. Inject dispatchers (ioDispatcher) rather than hard-coding Dispatchers.IO, or virtual time won’t apply to that work.

// A JUnit rule that swaps Dispatchers.Main for a test dispatcher
class MainDispatcherRule(
    val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
    override fun finished(description: Description) = Dispatchers.resetMain()
}

class FakeCartRepository : CartRepository {
    var items = listOf<CartItem>()
    var couponResult: Result<Discount> = Result.success(Discount.None)
    override suspend fun items() = items
    override suspend fun applyCoupon(code: String) = couponResult.getOrThrow()
}

class CartViewModelTest {
    @get:Rule val mainRule = MainDispatcherRule()
    private val repository = FakeCartRepository()

    @Test
    fun `shows the total with the coupon applied`() = runTest {
        repository.items = listOf(CartItem("lamp", priceCents = 5_000, quantity = 2))
        repository.couponResult = Result.success(Discount.Percent(10))
        val viewModel = CartViewModel(repository)

        viewModel.applyCoupon("SAVE10")
        advanceUntilIdle()                               // let the launched coroutines finish

        assertEquals(9_000, (viewModel.state.value as CartState.Loaded).totalCents)
    }

    @Test
    fun `keeps the cart and shows an error when the coupon fails`() = runTest {
        repository.items = listOf(CartItem("lamp", priceCents = 5_000, quantity = 1))
        repository.couponResult = Result.failure(IOException())
        val viewModel = CartViewModel(repository)

        viewModel.applyCoupon("SAVE10")
        advanceUntilIdle()

        val state = viewModel.state.value as CartState.Loaded
        assertEquals(5_000, state.totalCents)
        assertEquals("Couldn't apply the coupon. Check your connection.", state.message)
    }
}
  • MainDispatcherRule is written once and used in every ViewModel test.
  • The fake repository returns exactly what each test sets, so the failing-coupon case is one line, not airplane mode at the right moment.
  • advanceUntilIdle() runs everything the ViewModel launched, then the test checks the final state.
  • Backtick names describe the behavior in plain words; when a test fails, the name says what broke.
Spot the bugA test that checks too early

Tap the line with the bug.

class ProfileViewModelTest {
    @get:Rule val mainRule = MainDispatcherRule()        // StandardTestDispatcher

    @Test
    fun loadsTheProfile() = runTest {
        val viewModel = ProfileViewModel(FakeProfileRepository(name = "Ana"))
        viewModel.load()
        assertEquals(ProfileState.Loaded("Ana"), viewModel.state.value)
    }
}

Predict the output

A ViewModel retries a failed request after delay(10_000), up to three times. A test with a fake that always fails calls load() and then advanceUntilIdle() inside runTest. How long does the test take to run?

  • JVM tests with fakes; Dispatchers.setMain via a rule; runTest virtual time; advanceUntilIdle/runCurrent with StandardTestDispatcher.
  • Inject dispatchers and share the test scheduler; hard-coded Dispatchers.IO escapes virtual time.
  • Never Thread.sleep; assert final exposed state.

Check this part

  1. Why do ViewModel tests need a MainDispatcherRule?

  2. What does advanceUntilIdle() do?

  3. Why inject an ioDispatcher instead of using Dispatchers.IO directly?

Test ViewModels on the JVM with fake dependencies. Replace the Main dispatcher with a test dispatcher, run tests in runTest (virtual time, so delays are instant), let launched coroutines finish with advanceUntilIdle, and check the exposed state.

Part Not started

Testing Flows

Many ViewModels expose a StateFlow, and repositories return Flows. Checking only .value at the end works for many tests, but sometimes the sequence matters: Loading, then Loaded; or “the list updates when a new item is saved”. Turbine is a small library that collects a Flow inside a test and lets you check each value in order.

Turbine’s flow.test { awaitItem(); expectNoEvents(); cancelAndIgnoreRemainingEvents() } collects in a background coroutine within runTest, timing out on missing items and failing on unconsumed ones. Two subtleties: StateFlow conflates (a collector sees only the latest value when it resumes, so intermediate states produced without suspension in between are skipped), and stateIn(WhileSubscribed) flows only start upstream while collected, so tests must collect (Turbine, or backgroundScope.launch { flow.collect() }) for .value to update.

@Test
fun `the list updates when a note is saved`() = runTest {
    val dao = FakeNoteDao()                                 // emits on every change, like Room
    val viewModel = NotesViewModel(dao)

    viewModel.notes.test {
        assertEquals(emptyList<Note>(), awaitItem())         // initial value

        viewModel.add("Buy milk")
        assertEquals(listOf("Buy milk"), awaitItem().map { it.title })

        viewModel.add("Call Ana")
        assertEquals(listOf("Call Ana", "Buy milk"), awaitItem().map { it.title })
    }
}
  • test { } starts collecting the Flow; awaitItem() waits for the next value (failing after a timeout if none arrives).
  • At the end of the block, Turbine fails the test if there were values you didn’t check, which catches unexpected extra emissions.
  • viewModel.notes uses stateIn(WhileSubscribed): it only receives database updates while something collects it. Turbine’s collection is what makes it update.

Production puzzles

Spot the bugWhere did Loading go?

Tap the line with the bug.

// ViewModel
fun load() = viewModelScope.launch {
    _state.value = UiState.Loading
    _state.value = UiState.Loaded(repository.items())   // FakeRepository returns immediately
}

// Test, with UnconfinedTestDispatcher as Main
@Test
fun showsLoadingThenItems() = runTest {
    viewModel.state.test {
        assertEquals(UiState.Idle, awaitItem())
        viewModel.load()
        assertEquals(UiState.Loading, awaitItem())
        assertEquals(UiState.Loaded(items), awaitItem())
    }
}

Exercisemedium

Test a search with debounce

SearchViewModel has query: MutableStateFlow<String> and results: StateFlow<List<Product>>, built with query.debounce(300).flatMapLatest { repository.search(it) }.stateIn(...). Write a test that types “l”, “la”, “lamp” quickly and checks that the repository was asked only for “lamp”.

›

Use a fake repository that records the queries it receives. Inside runTest, advanceTimeBy moves virtual time. Something must collect results, or the debounce never runs.

SolutionTry it yourself first
class RecordingSearchRepository : SearchRepository {
    val queries = mutableListOf<String>()
    override fun search(text: String): Flow<List<Product>> {
        queries += text
        return flowOf(listOf(Product(text)))
    }
}

@Test
fun `searches only after typing pauses`() = runTest {
    val repository = RecordingSearchRepository()
    val viewModel = SearchViewModel(repository)

    viewModel.results.test {
        awaitItem()                                  // initial empty list
        viewModel.query.value = "l"
        advanceTimeBy(100)
        viewModel.query.value = "la"
        advanceTimeBy(100)
        viewModel.query.value = "lamp"
        advanceTimeBy(301)                           // typing paused long enough

        assertEquals(listOf(Product("lamp")), awaitItem())
        assertEquals(listOf("lamp"), repository.queries)
    }
}

The whole test runs in milliseconds, because advanceTimeBy moves virtual time; the 300 ms debounce never actually waits.

  • Turbine: test { awaitItem() }, fails on timeouts and unconsumed events.
  • StateFlow conflation skips intermediate states set without suspension; make fakes suspend (CompletableDeferred gates) to observe them, or assert outcomes only.
  • stateIn(WhileSubscribed) needs a collector in tests (Turbine or backgroundScope).

Check this part

  1. What does Turbine's awaitItem() do?

  2. Why can a test miss a Loading state on a StateFlow?

Test Flows by collecting them: Turbine's test { awaitItem() } checks each emission in order and fails on unexpected ones. StateFlow is conflated, so fast intermediate states can be skipped; test the sequence with a dispatcher that lets them be observed, or assert only the final state.

Part Not started

UI tests and device tests

Compose UI tests render a composable, find elements the way a user (or TalkBack) would, by their text or description, click them, and check what’s shown. Because Compose describes the screen in a semantics tree, tests don’t need view ids. The easiest UI tests give a screen composable a state directly, so no ViewModel or network is involved at all.

createComposeRule() (or createAndroidComposeRule<Activity>()) provides finders (onNodeWithText, onNodeWithContentDescription, onNodeWithTag), actions (performClick, performTextInput, performScrollToNode) and assertions over the merged semantics tree; it synchronizes with recomposition and idling, so explicit waits are rarely needed (waitUntil for async conditions). Testing stateless screen composables with fixed states gives fast, deterministic tests and pairs well with screenshot testing (Roborazzi, Paparazzi, Compose Preview Screenshot Testing). Robolectric runs Compose tests on the JVM in CI; real devices or emulators (Gradle Managed Devices) are for instrumented tests of Room, WorkManager, permissions and a handful of end-to-end flows.

class CartScreenTest {
    @get:Rule val compose = createComposeRule()

    @Test
    fun showsItemsAndTotal_andCheckoutCallsBack() {
        var checkoutClicked = false
        compose.setContent {
            CartScreen(
                state = CartState.Loaded(
                    items = listOf(CartItem("Desk lamp", priceCents = 3_499, quantity = 2)),
                    totalCents = 6_998,
                ),
                onCheckout = { checkoutClicked = true },
            )
        }

        compose.onNodeWithText("Desk lamp").assertIsDisplayed()
        compose.onNodeWithText("Total: €69.98").assertIsDisplayed()
        compose.onNodeWithText("Checkout").performClick()

        assertTrue(checkoutClicked)
    }

    @Test
    fun showsAnErrorWithRetry() {
        var retried = false
        compose.setContent { CartScreen(state = CartState.Error("No connection"), onRetry = { retried = true }) }

        compose.onNodeWithText("No connection").assertIsDisplayed()
        compose.onNodeWithText("Try again").performClick()
        assertTrue(retried)
    }
}
  • CartScreen(state = …) takes its data as a parameter (the ViewModel is connected one level up), so the test just passes a state.
  • Find by what users see: text and content descriptions. That also checks the screen makes sense to TalkBack. Use Modifier.testTag("cart.total") only when there’s no stable text.
  • Callbacks like onCheckout are checked with a simple variable; no mocking framework needed.

What to run where

LevelRuns onGood forCount
Unit (ViewModel, repository with fakes, mappers)JVM, src/testLogic, state, errorsMany
Compose UI with fixed state, screenshotsJVM (Robolectric) or deviceLayout, text, callbacks per stateSome per screen
Integration (Room DAO, WorkManager, Hilt graph)Robolectric or deviceReal SQL, migrations, schedulingFocused
End-to-end flows (login, checkout)Device / managed emulator, stubbed backendWiring between screensFew

Flakiness on devices comes from animations (disable them in test devices), real network (stub with MockWebServer), shared state between tests (clear app data per test with Orchestrator), and waiting on time (use idling or waitUntil, never sleeps).

Predict the output

A Compose test sets content with a CartScreen whose total text is 'Total: €69.98', then calls onNodeWithText('Total: 69.98 €').assertIsDisplayed(). What happens?

  • Compose tests: semantic finders, actions, assertions; automatic synchronization; waitUntil for async.
  • Test stateless screen composables with fixed states; add screenshot tests (Roborazzi/Paparazzi).
  • Robolectric for JVM UI and integration tests; managed devices for Room/WorkManager/permissions and a few E2E flows with stubbed backends.
  • Flakiness: animations, real network, shared app state (Orchestrator), sleeps.

Check this part

  1. What makes a Compose screen easy to UI-test?

  2. Which tests belong on a device or emulator rather than the JVM?

Compose UI tests find elements by semantics (text, content descriptions, test tags) and act on them; they run on the JVM with Robolectric or on a device. Keep them focused on what the screen shows for a given state. Instrumented tests on devices are for real storage, system integration and a few end-to-end flows.

Key takeaways

  • Most tests belong to ViewModels: fakes in, state out, on the JVM in milliseconds.
  • Swap the Main dispatcher with a rule, run in runTest, and call advanceUntilIdle() before checking state.
  • Use Turbine to check Flow emissions in order.
  • Test screen composables by passing a state and finding elements by their text.
  • Inject dispatchers; share the test scheduler; virtual time makes delays free.
  • StateFlow conflation and WhileSubscribed need deliberate handling in tests.
  • Fixed-state Compose tests and screenshots per state; Robolectric in CI.
  • A thin layer of device tests with stubbed backends, no animations, isolated app state.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.