name: android-testing
description: |
Testing patterns for Android/KMP - ViewModel unit tests with JUnit5, Turbine, AssertK, UnconfinedTestDispatcher, fake repositories, SavedStateHandle, and Compose UI tests. Use this skill whenever writing or reviewing tests for ViewModels, repositories, use cases, or Compose screens. Trigger on phrases like “write a test”, “unit test the ViewModel”, “test a repository”, “Turbine”, “fake repository”, “UnconfinedTestDispatcher”, “runTest”, “ComposeTestRule”, or “JUnit5”.
Android / KMP Testing
Stack
| Concern | Library |
|---|
| Test framework | JUnit5 |
| Assertions | AssertK |
| Flow / StateFlow testing | Turbine |
| Coroutine testing | kotlinx-coroutines-test + UnconfinedTestDispatcher |
| UI testing | ComposeTestRule |
ViewModel Unit Tests
Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
| class NoteListViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@BeforeEach
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
}
|
Testing State with Turbine
1
2
3
4
5
6
7
8
9
10
11
| @Test
fun `loading notes emits notes in state`() = runTest {
val repo = FakeNoteRepository()
val viewModel = NoteListViewModel(repo)
viewModel.state.test {
viewModel.onAction(NoteListAction.OnRefreshClick)
assertThat(awaitItem().isLoading).isTrue()
assertThat(awaitItem().notes).isNotEmpty()
}
}
|
Testing Events (one-time side effects)
1
2
3
4
5
6
7
8
9
| @Test
fun `clicking note sends NavigateToDetail event`() = runTest {
val viewModel = NoteListViewModel(FakeNoteRepository())
viewModel.events.test {
viewModel.onAction(NoteListAction.OnNoteClick("123"))
assertThat(awaitItem()).isEqualTo(NoteListEvent.NavigateToDetail("123"))
}
}
|
Fake Repositories
Prefer fakes (not mocks) for repository dependencies. A fake is a simple in-memory implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| class FakeNoteRepository : NoteRepository {
private val notes = mutableListOf<Note>()
var shouldReturnError = false
override suspend fun getNotes(): Result<List<Note>, DataError.Local> {
return if (shouldReturnError) Result.Error(DataError.Local.UNKNOWN)
else Result.Success(notes.toList())
}
override suspend fun insertNote(note: Note): EmptyResult<DataError.Local> {
notes.add(note)
return Result.Success(Unit)
}
}
|
SavedStateHandle in Tests
Instantiate it directly — no mocking needed:
1
2
| val savedStateHandle = SavedStateHandle(mapOf("noteId" to "123"))
val viewModel = NoteEditorViewModel(savedStateHandle, FakeNoteRepository())
|
When to Inject Dispatchers
Only inject CoroutineDispatcher into a class when:
- It dispatches to a non-main dispatcher (e.g.,
IO), AND - That class is directly unit-tested.
ViewModels that only use viewModelScope do not need injected dispatchers. Use Dispatchers.setMain() in tests instead.
If a non-ViewModel class uses withContext(Dispatchers.IO) and is unit-tested, inject the dispatcher:
1
2
3
4
5
6
| class ImageCompressor(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
suspend fun compress(bytes: ByteArray): ByteArray = withContext(ioDispatcher) { ... }
}
// In test:
val compressor = ImageCompressor(ioDispatcher = UnconfinedTestDispatcher())
|
Integration and E2E Tests
Write integration tests where database/network interactions are non-trivial. Write E2E tests for complex user flows using ComposeTestRule:
1
2
3
4
5
6
7
8
9
10
11
12
13
| @get:Rule
val composeTestRule = createComposeRule()
@Test
fun noteList_displaysNotes_afterLoad() {
composeTestRule.setContent {
NoteListScreen(
state = NoteListState(notes = listOf(NoteUi("1", "Hello", "Mar 15"))),
onAction = {}
)
}
composeTestRule.onNodeWithText("Hello").assertIsDisplayed()
}
|
Robot Pattern (Complex UI / E2E Tests)
For complex end-to-end or multi-step UI tests, use the Robot Pattern to separate test intent from Compose interactions. A robot encapsulates all composeTestRule interactions for a screen, keeping tests readable and DRY.
Structure
Every robot function returns this so calls can be chained like a builder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Robot class — owns all UI interactions for the screen
class NoteListRobot(private val composeTestRule: ComposeContentTestRule) {
fun setContent(
state: NoteListState,
onAction: (NoteListAction) -> Unit = {}
) = apply {
composeTestRule.setContent {
NoteListScreen(state = state, onAction = onAction)
}
}
fun assertNoteVisible(title: String) = apply {
composeTestRule.onNodeWithText(title).assertIsDisplayed()
}
fun clickNote(title: String) = apply {
composeTestRule.onNodeWithText(title).performClick()
}
fun assertEmptyState() = apply {
composeTestRule.onNodeWithTag("empty_state").assertIsDisplayed()
}
}
|
Usage in Tests
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| class NoteListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val robot by lazy { NoteListRobot(composeTestRule) }
@Test
fun displaysNotes_afterLoad() {
robot
.setContent(NoteListState(notes = listOf(NoteUi("1", "Hello", "Mar 15"))))
.assertNoteVisible("Hello")
}
@Test
fun showsEmptyState_whenNoNotes() {
robot
.setContent(NoteListState(notes = emptyList()))
.assertEmptyState()
}
@Test
fun clickingNote_triggersAction() {
var clickedId: String? = null
robot
.setContent(
state = NoteListState(notes = listOf(NoteUi("1", "Hello", "Mar 15"))),
onAction = { if (it is NoteListAction.OnNoteClick) clickedId = it.noteId }
)
.assertNoteVisible("Hello")
.clickNote("Hello")
}
}
|
When to use: Apply the robot pattern when a screen has 3+ UI test cases, when multiple tests share the same setup/assertion sequences, or when testing complex multi-step user flows (e.g., fill form → submit → assert result).
What to Test
- Unit-test every ViewModel and any non-trivial domain/data logic.
- Unit-test any logic that is likely to change.
- Use fakes over mocks where possible — fakes are simpler and catch more real bugs.
- Write integration tests where DB/network interactions are non-trivial.
- Write E2E Compose tests for critical user flows.
- Use the robot pattern for complex UI/E2E tests with multiple test cases or shared interaction sequences.