Testing Android apps

JVM versus instrumented tests, coroutine test dispatchers, fakes over mocks, Compose UI testing and screenshot tests.

Choosing the level

LevelRunsSpeedCatches
Unit (JVM)No deviceMillisecondsLogic, state machines, mapping
Compose UIJVM with Robolectric or a deviceSecondsRendering, semantics, interaction
InstrumentedDevice or emulatorSeconds to minutesRoom, DataStore, real lifecycle
ScreenshotDevice or hostSecondsVisual regressions across themes and sizes
class FakeBookRepository : BookRepository {
    private val books = MutableStateFlow<List<Book>>(emptyList())
    var failure: Throwable? = null

    override fun observeBooks(): Flow<List<Book>> = books
    override suspend fun refresh(): Result<Unit> {
        failure?.let { return Result.failure(it) }
        books.value = listOf(Book(1, "The Dispossessed", 2))
        return Result.success(Unit)
    }
}

class BookViewModelTest {

    private val dispatcher = StandardTestDispatcher()
    private val repo = FakeBookRepository()

    @Before fun setUp() = Dispatchers.setMain(dispatcher)
    @After fun tearDown() = Dispatchers.resetMain()

    @Test fun showsBooksAfterRefresh() = runTest {
        val vm = BookViewModel(repo)
        vm.refresh()
        advanceUntilIdle()                       // run the queued coroutines
        assertEquals(listOf(Book(1, "The Dispossessed", 2)), vm.uiState.value.books)
    }

    @Test fun reportsFailure() = runTest {
        repo.failure = IOException("offline")
        val vm = BookViewModel(repo)
        vm.refresh()
        advanceUntilIdle()
        assertEquals("offline", vm.uiState.value.error)
    }
}
  • Dispatchers.setMain with a test dispatcher is mandatory for any code using viewModelScope, or the test asserts before the coroutine has run.
  • advanceUntilIdle() drains the queue deterministically, which is what removes flakiness from a coroutine test.
  • A hand-written fake repository is usually better than a mock: it holds state, so it exercises the real flow of the code rather than asserting call sequences.
  • Test the failure path. Error handling is where the untested code lives, and it is the path users hit on a train.
💡
Test what the user can see. Asserting that a ViewModel called repo.refresh() verifies your implementation; asserting the rendered state after an action verifies the behaviour you actually ship. Prefer the second, and reach for fakes so it is possible.

Compose UI tests

class BooksScreenTest {

    @get:Rule val composeRule = createComposeRule()

    @Test fun showsEmptyState() {
        composeRule.setContent {
            AppTheme { BooksScreen(state = BookUiState(books = emptyList()), onEvent = {}, onOpenBook = {}) }
        }
        composeRule.onNodeWithText("No books yet").assertIsDisplayed()
    }

    @Test fun clickingABookReportsTheId() {
        var selected: Long? = null
        composeRule.setContent {
            AppTheme {
                BooksScreen(
                    state = BookUiState(books = listOf(Book(7, "A", 1))),
                    onEvent = {},
                    onOpenBook = { selected = it },
                )
            }
        }
        composeRule.onNodeWithText("A").performClick()
        assertEquals(7L, selected)
    }

    @Test fun loadingHidesTheList() {
        composeRule.setContent { AppTheme { BooksScreen(BookUiState(loading = true), {}, {}) } }
        composeRule.onNodeWithTag("book-list").assertDoesNotExist()
        composeRule.onNodeWithTag("loading").assertIsDisplayed()
    }
}
  • Prefer finding by text or content description, which is what a user does. Use testTag only when the text is dynamic or repeated.
  • Add useUnmergedTree = true when a semantic node is merged into its parent and the finder cannot see it.
  • Test the screen with a state object directly - no ViewModel, no network, no database - which is possible only because the composable is stateless.
  • Screenshot tests catch layout regressions that semantic assertions miss: a clipped label, a missing contrast, a font scale that breaks the layout.
// screenshot testing makes layout regressions visible in review
@Test fun booksScreenLightTheme() {
    captureRoboImage("build/screenshots/books-light.png") {
        AppTheme(darkTheme = false) { BooksScreen(sampleState, {}, {}) }
    }
}

Instrumented tests

@RunWith(AndroidJUnit4::class)
class BookDaoTest {

    private lateinit var db: AppDatabase

    @Before fun create() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java,
        ).build()
    }

    @After fun close() = db.close()

    @Test fun upsertReplacesExistingRows() = runTest {
        db.bookDao().upsert(listOf(BookEntity(1, "First", 1, 0)))
        db.bookDao().upsert(listOf(BookEntity(1, "Second", 1, 0)))
        assertEquals("Second", db.bookDao().byId(1)?.title)
    }

    @Test fun observesChanges() = runTest {
        val emissions = mutableListOf<List<BookEntity>>()
        val job = launch { db.bookDao().observeAll().toList(emissions) }
        db.bookDao().upsert(listOf(BookEntity(1, "A", 1, 0)))
        advanceUntilIdle()
        assertTrue(emissions.isNotEmpty())
        job.cancel()
    }
}
  • An in-memory Room database runs the real SQLite engine, so the SQL is genuinely exercised - unlike a mocked DAO.
  • Test the migration separately with MigrationTestHelper, creating the old version and validating the new schema.
  • Instrumented tests need a device or an emulator, so keep the suite small and focused on things the JVM cannot test: real lifecycle, real storage, real rendering.
  • Run them in CI with a managed device or a Gradle Managed Device, and quarantine flaky tests rather than re-running the whole suite.

FAQ

How do I test a screen that depends on a ViewModel?
Extract a stateless composable that takes the state and the callbacks, and test that. Add one integration test that renders the real screen with a fake repository, which is enough to catch wiring mistakes.
Why do my coroutine tests fail intermittently?
You are not controlling the main dispatcher, or you assert before the coroutine completes. Set a test dispatcher, use runTest, and call advanceUntilIdle() before asserting - never use a real delay in a test.

Dependency injection with Hilt Local persistence with Room and DataStore

Last refreshed 2026-09-18.