Testing & Quality
Unit, integration, and UI tests; test doubles; reliable coroutine tests; and keeping the test suite useful as an app grows.
Testing questions are usually practical. Interviewers want to know what you would test, where the test should run, and whether the result gives the team confidence without slowing development to a crawl.
What gets tested
- Test scope: unit, integration, instrumented, UI, and end-to-end tests.
- Test doubles: fakes, mocks, stubs, and when each one helps.
- Android components: ViewModels, repositories, Room, and Compose UI.
- Asynchronous code: coroutine test dispatchers, virtual time, and Flow assertions.
- Reliability: removing sleeps, controlling dependencies, and diagnosing flaky tests.
- Strategy: choosing a small set of meaningful tests instead of chasing coverage numbers.
How interviewers ask
Expect a feature or class and a simple prompt: “How would you test this?” Start with the behavior that matters, identify the boundaries you control, and choose the cheapest test that can prove the behavior. Mention slower device tests only when Android framework behavior is part of what you need to verify.
Prep tip: explain one happy path, one failure path, and one edge case for a feature you have built. Then say which tests you would not write and why.
Start here
Core ideas you should be able to explain in plain language.
Core concepts
What is the difference between local and instrumented tests on Android?
Local tests run on the JVM on your development machine. They are fast and work well for business logic, mappers, reducers, and ViewModels whose Android dependencies have been kept behind interfaces.
Instrumented tests run on an Android device or emulator. Use them when the behavior depends on the framework, such as navigation, permissions, resources, Room integration, or a complete UI flow. They provide more realism but are slower and need more setup.
Robolectric sits between the two: it runs Android-like behavior on the JVM. It can be useful, but it is not a replacement for every device test.
A good default is to keep most tests local, add integration tests at important boundaries, and reserve device tests for behavior that genuinely requires Android.
What should an Android app's testing strategy look like?
The test pyramid guides where to invest: many fast tests at the bottom, few slow ones at the top.
/\ UI / E2E tests (few - slow, brittle, on-device)
/ \ Espresso / Compose UI tests, full flows
/----\ Integration tests (some)
/ \ Room DAO, repository + fakes, navigation
/--------\ Unit tests (many - fast, JVM)
/__________\ ViewModels, use cases, mappers, pure logic
Unit tests (the base, most of your tests):
- Run on the JVM (no device) → fast, run on every change.
- Target pure logic: ViewModels, use cases, mappers, formatters, repositories (with fake data sources).
- Use
runTestfor coroutines, inject dispatchers, Turbine for flows.
Integration tests (middle):
- Verify components together: Room DAOs against an in-memory DB, a repository with real DB + fake network, navigation graphs.
- Some run on JVM (Robolectric) or instrumented.
UI / End-to-end (top, few):
- Espresso (Views) / Compose UI tests / UI Automator drive real screens and flows.
- Slow and flakier, so cover critical user journeys (login, checkout), not every screen.
What makes the app testable (the real point):
- Architecture enables testing - DI + interfaces let you inject fakes; UDF makes ViewModels pure functions of input you can assert on; separating layers keeps logic Android-free.
- Inject dispatchers and clocks so time/threading is controllable.
- Prefer fakes over heavy mocking, and test behavior, not implementation.
Other tools: screenshot tests (Paparazzi/Roborazzi) for visual regression, Macrobenchmark for performance, and Play Pre-launch reports for device coverage.
Use it in practice
Common implementation choices, debugging, and trade-offs.
Core concepts
Fakes vs mocks vs stubs - which should you prefer and why?
All three are test doubles that stand in for real dependencies, but they differ:
- Stub - returns canned answers to calls (
whenever(repo.get()).thenReturn(data)). No real behavior. - Mock - a stub that also verifies interactions (“was
save()called once with X?”). Created with frameworks like MockK/Mockito. - Fake - a real, working lightweight implementation of the interface (e.g. an in-memory repository backed by a
MutableList/MutableStateFlow).
// Fake: a real, simple implementation
class FakeUserRepository : UserRepository {
private val users = MutableStateFlow<List<User>>(emptyList())
override fun observeUsers() = users.asStateFlow()
override suspend fun add(user: User) { users.update { it + user } }
}
Prefer fakes (Google’s guidance) because:
- They test behavior, not implementation - you assert on the resulting state, not on “which methods were called.” Mocks couple tests to internal call sequences, so refactors break tests even when behavior is unchanged (“brittle tests”).
- A fake supports realistic flows (add then observe → emits the new list), which is exactly what
Flow-based code needs. Mocking aFlow’s emissions over time is painful and error-prone. - Fakes are reusable across many tests and read clearly.
When mocks are still useful:
- Verifying an interaction is the requirement - e.g. “analytics
track()was called,” “the repository’ssync()was invoked.” There’s no state to assert, so verifying the call is legitimate. - Simulating errors/edge cases that are awkward to build into a fake (a specific exception on the 3rd call).
- Quick isolation of a dependency you don’t want to implement.
Anti-pattern interviewers watch for: mock-heavy tests that mirror the implementation line-by-line - they pass even when the code is wrong and break on every refactor.
How do you test Compose UI?
Compose tests use a semantics tree (the same accessibility tree screen readers use), not view IDs. You find nodes, assert on them, and perform actions through a ComposeTestRule.
class CounterTest {
@get:Rule val rule = createComposeRule()
@Test fun increments() {
rule.setContent { Counter() }
rule.onNodeWithText("Count: 0").assertIsDisplayed()
rule.onNodeWithContentDescription("Increment").performClick()
rule.onNodeWithText("Count: 1").assertExists()
}
}
The pieces:
- Finders -
onNodeWithText,onNodeWithTag(Modifier.testTag("...")),onNodeWithContentDescription,onAllNodes. - Assertions -
assertIsDisplayed,assertExists,assertIsEnabled,assertTextEquals. - Actions -
performClick,performTextInput,performScrollTo,performTouchInput. createComposeRulefor pure Compose;createAndroidComposeRule<Activity>()when you need a real Activity/host.
Synchronization: the test framework auto-syncs with recomposition and Compose-driven animations/coroutines - waitForIdle() happens implicitly between actions, so you rarely sleep. For non-Compose async, use waitUntil { }. You can disable auto-advance and control the clock with mainClock for animation tests.
Good practices:
- Add
testTagfor elements without stable text. - Test stateless composables by passing state directly - easy because they’re pure functions of inputs.
@Preview+ screenshot testing (Paparazzi / Roborazzi) catches visual regressions without a device.
How do you unit-test a ViewModel?
A ViewModel is testable precisely because it’s a function of injected dependencies and input events → emitted state. Inject a fake repository, drive events, assert on the emitted UiState.
class FeedViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before fun setup() { Dispatchers.setMain(dispatcher) } // for viewModelScope
@After fun tearDown() { Dispatchers.resetMain() }
@Test fun `loads feed successfully`() = runTest {
val repo = FakeFeedRepository(items = listOf(post1, post2))
val vm = FeedViewModel(repo)
vm.state.test { // Turbine
assertEquals(FeedUiState(loading = true), awaitItem())
val loaded = awaitItem()
assertEquals(listOf(post1, post2), loaded.items)
assertFalse(loaded.loading)
}
}
@Test fun `shows error when repo fails`() = runTest {
val vm = FeedViewModel(FakeFeedRepository(error = IOException()))
vm.refresh()
advanceUntilIdle()
assertNotNull(vm.state.value.errorMessage)
}
}
The essentials:
Dispatchers.setMain(testDispatcher)in setup -viewModelScoperuns onDispatchers.Main, which doesn’t exist in unit tests; replace it. Reset in teardown.runTestgives a virtual clock (delays skipped) andadvanceUntilIdle()to flush coroutines.- Inject dispatchers into the ViewModel/repo rather than hardcoding
Dispatchers.IO, so tests are deterministic. - Fake, don’t hit real I/O - a
FakeRepositoryreturning canned data/errors. Prefer fakes over mocking frameworks for state. - Assert on emitted state with Turbine (
.test { awaitItem() }) or by collecting into a list. - Use
InstantTaskExecutorRuleif testingLiveData.
What to test: initial state, success path, error/empty paths, that events produce the right state transitions, and that one-off events are emitted.
How would you test a repository that combines a network API and Room?
Test the repository as a unit by giving it controlled dependencies: a fake API, a fake or in-memory data source, and a test dispatcher. Verify behavior rather than implementation details.
Useful cases include:
- Cached data is returned while a refresh is in progress.
- A successful response is saved and then observed from the database.
- A network failure preserves usable cached data and exposes the right error.
- Repeated refreshes do not create duplicate rows.
- Cancellation stops work instead of being converted into a normal failure.
Add a smaller Room integration test with an in-memory database when queries, transactions, migrations, or conflict rules are important. There is usually no value in mocking every DAO call and then asserting that each mock was invoked. That only repeats the implementation in the test.
What makes an Android test flaky, and how do you fix it?
A flaky test passes and fails without a relevant code change. Common causes are real time, uncontrolled dispatchers, shared state, network calls, animations, device differences, and assertions that run before the UI or background work is idle.
Start by reproducing the failure repeatedly and recording the seed, device, and logs. Then remove the uncontrolled dependency:
- Use virtual time for coroutines instead of
delayorThread.sleep. - Inject clocks, dispatchers, IDs, and external services.
- Reset databases, files, and singletons between tests.
- Use Compose or Espresso synchronization instead of fixed waits.
- Give each test its own data and avoid depending on execution order.
Retries may reduce CI noise, but they do not fix the test. Quarantine can be a short-term containment step only when the failure has an owner and a deadline.
Optional deep dives
Internals and broader design questions to study after the core material.
Core concepts
How do you test coroutines and flows?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
The toolkit from kotlinx-coroutines-test:
runTest { } - the entry point for coroutine tests. It runs on a virtual clock, so delay(10_000) completes instantly (time is skipped, not waited). It also auto-waits for child coroutines.
@Test
fun loadsData() = runTest {
val vm = MyViewModel(fakeRepo)
vm.load()
advanceUntilIdle() // run all pending coroutines
assertEquals(Expected, vm.state.value)
}
Test dispatchers:
StandardTestDispatcher- coroutines are queued, not run eagerly; you drive them withadvanceUntilIdle()/advanceTimeBy(). Good for controlling ordering.UnconfinedTestDispatcher- runs coroutines eagerly to their first suspension. Simpler when you don’t care about precise scheduling.
Injecting the dispatcher is the key design point: don’t hardcode Dispatchers.IO - inject a dispatcher so tests can swap in a test one.
class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
suspend fun load() = withContext(io) { /* ... */ }
}
Replacing Dispatchers.Main (for viewModelScope): in setup call Dispatchers.setMain(testDispatcher), and Dispatchers.resetMain() in teardown.
Testing flows - collect manually, or use Turbine for ergonomic assertions:
viewModel.state.test { // Turbine
assertEquals(Loading, awaitItem())
assertEquals(Loaded(data), awaitItem())
cancelAndIgnoreRemainingEvents()
}