Testing cheat sheet

The 9 Testing interview answers in 30 seconds each, plus quick facts. Printable, free.

One page per topic: print it or save it as PDF from your browser's print menu. Practise these questions

Rules of thumb

  • Most tests are local JVM tests; few run on a device.
  • Prefer fakes; mock only interactions you must verify.
  • runTest plus injected dispatchers; never sleep.
  • Collect hot flows in backgroundScope or with Turbine.
  • Test migrations with MigrationTestHelper.

Local vs instrumented tests

Local tests run on the JVM on your machine in src/test: fast, cheap and ideal for ViewModels, use cases, repositories with fakes, and mappers. Instrumented tests run on a device or emulator in src/androidTest: slower, but they test real Android behaviour like Room, UI and navigation. Write many local tests, fewer integration tests, and a small number of end-to-end UI tests.

Fakes, mocks and stubs

A stub returns canned answers. A mock records calls so you can verify interactions, such as with MockK. A fake is a small working implementation, such as an in-memory repository backed by a MutableStateFlow. Google's guidance prefers fakes for your own interfaces: they're simple, reusable and test behaviour, while mocks tie tests to implementation details.

interface NotesRepository {
    val notes: Flow<List<String>>
    suspend fun add(note: String)
}

class FakeNotesRepository : NotesRepository {
    private val state = MutableStateFlow<List<String>>(emptyList())
    var failNext = false
    override val notes: Flow<List<String>> = state
    override suspend fun add(note: String) {
        if (failNext) { failNext = false; throw java.io.IOException("offline") }
        state.update { it + note }
    }
}

Testing coroutines with runTest

Use runTest { } from kotlinx-coroutines-test. It runs on a TestScope with virtual time, so delay(10_000) finishes instantly, and it fails if coroutines leak. Replace Dispatchers.Main with Dispatchers.setMain(StandardTestDispatcher()) in setup (and resetMain after), and inject other dispatchers so tests can pass the test dispatcher.

class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : org.junit.rules.TestWatcher() {
    override fun starting(description: org.junit.runner.Description) = Dispatchers.setMain(dispatcher)
    override fun finished(description: org.junit.runner.Description) = Dispatchers.resetMain()
}

Testing Flows

For a finite Flow, flow.toList() or first() is enough. For hot flows like StateFlow, collect in backgroundScope into a list, trigger actions, and assert on what was collected, or use the Turbine library: flow.test { assertEquals(a, awaitItem()); ... cancelAndIgnoreRemainingEvents() }. Remember StateFlow conflates, so fast intermediate values may be skipped.

Testing a ViewModel

Construct it with fakes for its dependencies and a SavedStateHandle you create in the test, replace Main with a test dispatcher, call its event functions, and assert on the UiState it exposes. Cover the loading, success, empty and error paths. No Android device is needed, because the ViewModel holds no views or Activity.

class FeedViewModelTest {
    @get:Rule val main = MainDispatcherRule()

    @Test
    fun refreshShowsItems() = runTest {
        val vm = FeedViewModel(load = { listOf(Article("1", "Hello")) })
        vm.onRefresh()
        assertEquals(listOf(Article("1", "Hello")), vm.state.value.items)
        assertFalse(vm.state.value.isRefreshing)
    }
}

Compose UI tests

Use createComposeRule() (or createAndroidComposeRule for a real Activity), set content, then find nodes through the semantics tree with finders like onNodeWithText or onNodeWithTag, perform actions such as performClick(), and assert with assertIsDisplayed() or assertTextEquals(). Compose tests wait for idle automatically, so there are no sleeps.

@Composable
fun NotesTestScreen() {
    var notes by remember { mutableStateOf(listOf<String>()) }
    Column {
        Button(onClick = { notes = notes + "Note ${notes.size + 1}" }) { Text("Add") }
        notes.forEach { Text(it) }
    }
}

Screenshot tests

A screenshot test renders a composable or screen to an image and compares it with a stored reference, failing when pixels change. Tools such as Compose Preview Screenshot Testing, Paparazzi and Roborazzi run them on the JVM without a device. They catch visual regressions in design-system components and screens across themes, font sizes and locales that assertion tests miss.

Testing Room and migrations

For DAOs, build an in-memory database with Room.inMemoryDatabaseBuilder, insert data, and assert on query results, including Flow emissions. These usually run as instrumented tests, or on the JVM with Robolectric or the bundled SQLite driver. For migrations, use MigrationTestHelper: create the database at the old version from the exported schema, insert rows, run the migration and check the data.

Flaky tests

Flaky tests pass and fail without code changes. Common causes: real time (sleeps, timeouts), work the framework doesn't know to wait for (custom threads, uncontrolled dispatchers), animations, shared state between tests, real network, and ordering assumptions. Fix by injecting test dispatchers and fakes, using idling resources or Compose's automatic sync, disabling animations, and isolating state per test.

Quick facts

Turbine
Turbine is a small library for testing Flows with awaitItem(), awaitComplete() and awaitError().
UnconfinedTestDispatcher
UnconfinedTestDispatcher starts coroutines eagerly; StandardTestDispatcher queues them until advanced.
Robolectric
Robolectric runs Android framework code on the JVM, so some Android tests run without a device.
Gradle Managed Devices
Gradle Managed Devices define emulators in the build so CI can run instrumented tests.