Testing Android apps
JVM versus instrumented tests, coroutine test dispatchers, fakes over mocks, Compose UI testing and screenshot tests.
Choosing the level
| Level | Runs | Speed | Catches |
|---|---|---|---|
| Unit (JVM) | No device | Milliseconds | Logic, state machines, mapping |
| Compose UI | JVM with Robolectric or a device | Seconds | Rendering, semantics, interaction |
| Instrumented | Device or emulator | Seconds to minutes | Room, DataStore, real lifecycle |
| Screenshot | Device or host | Seconds | Visual 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.setMainwith a test dispatcher is mandatory for any code usingviewModelScope, 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
testTagonly when the text is dynamic or repeated. - Add
useUnmergedTree = truewhen 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.Related
Dependency injection with Hilt Local persistence with Room and DataStore
Last refreshed 2026-09-18.