Test the full event→state→effect cycle through the ViewModel. Use kotlinx-coroutines-test with the Turbine library:
@Test
fun `save with empty title shows validation error`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.state.test {
val initial = awaitItem()
assertTrue(initial.errors.isEmpty())
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val afterSave = awaitItem()
assertEquals("Title is required", afterSave.errors["title"])
assertFalse(afterSave.isSaving)
}
}
@Test
fun `save with valid input transitions through saving to success`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.state.test {
awaitItem() // initial
viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
awaitItem()
viewModel.onEvent(CreateItemEvent.OnAmountChanged("42.5"))
awaitItem()
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val saving = awaitItem()
assertTrue(saving.isSaving)
val done = awaitItem()
assertFalse(done.isSaving)
}
}
@Test
fun `title changed clears title validation error`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.state.test {
awaitItem() // initial
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val withError = awaitItem()
assertTrue(withError.errors.containsKey("title"))
viewModel.onEvent(CreateItemEvent.OnTitleChanged("A"))
val cleared = awaitItem()
assertFalse(cleared.errors.containsKey("title"))
}
}
@Test
fun `save emits ShowMessage effect on success`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
viewModel.onEvent(CreateItemEvent.OnAmountChanged("10"))
viewModel.effect.test {
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val effect = awaitItem()
assertTrue(effect is CreateItemEffect.ShowMessage)
}
}
What to test:
When a single event produces both state changes and effects, test them independently for clarity:
@Test
fun `back click emits NavigateBack effect without changing state`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.effect.test {
viewModel.onEvent(CreateItemEvent.OnBackClick)
assertEquals(CreateItemEffect.NavigateBack, awaitItem())
}
viewModel.state.test {
val state = awaitItem()
assertEquals(CreateItemState(), state)
}
}
Test validation logic as pure functions when extracted into a dedicated validator:
@Test
fun `validator rejects blank title`() {
val errors = CreateItemValidator.validate(title = "", amount = "10")
assertEquals("Title is required", errors["title"])
}
@Test
fun `validator accepts valid input`() {
val errors = CreateItemValidator.validate(title = "Widget", amount = "25.0")
assertTrue(errors.isEmpty())
}
If validation is inline in the ViewModel (acceptable for simple cases), test it through ViewModel events as shown above.
Test pure calculation/domain services directly — no ViewModel needed:
@Test
fun `calculator computes correct monthly payment`() {
val result = LoanCalculator.monthlyPayment(amount = 100000.0, rate = 5.0, years = 30)
assertEquals(536.82, result, 0.01)
}
Test: edge cases, rounding policy, domain invariants, regression fixtures.
Use fakes (not mocks) for repositories and services:
class FakeItemRepository : ItemRepository {
private val items = mutableListOf<Item>()
var shouldThrow: Exception? = null
override suspend fun create(title: String, amount: Double) {
shouldThrow?.let { throw it }
items.add(Item(title = title, amount = amount))
}
override suspend fun getAll(): List<Item> = items.toList()
}
Fakes give you control over success/failure scenarios without mock framework complexity.
Compose Multiplatform common UI testing uses runComposeUiTest rather than Android's JUnit TestRule model.
Test:
Test:
Per-platform rendering, typography, and layout differ; shared Android/iOS goldens are brittle.
Default: prefer semantic assertions and interaction tests; use per-platform visual goldens only for a few high-value screens.
Do not sink weeks into screenshot infrastructure before you have ViewModel test coverage.
| Anti-pattern | Why it hurts | Better replacement |
|---|---|---|
| No ViewModel tests, only UI tests | slow feedback, flaky, hard to isolate failures | ViewModel event→state→effect tests with Turbine first |
| Testing implementation details (private functions, internal state) | brittle tests that break on refactoring | test through public API: send event, assert state/effect |
| Mocking the DI framework | couples tests to DI internals | swap real implementations with fakes via constructor injection |
| Screenshot tests before ViewModel coverage | high maintenance, low defect yield | establish ViewModel + validator coverage first, then add screenshots selectively |
| Testing derived/computed properties in isolation from ViewModel | duplicates logic, drifts from real behavior | test derived values through ViewModel state assertions |
| Sharing mutable test fixtures across tests | hidden coupling, order-dependent failures | fresh state per test, explicit setup in each test function |
Some reference files contain their own testing sections with domain-specific patterns:
| Domain | Reference | What it covers |
|---|---|---|
| Paging 3 | paging-mvi-testing.md | PagingSource unit tests, asSnapshot, TestPager transformations |
| Room Database | room-database.md | In-memory DB tests, migration tests, fake DAOs |
| Networking | networking-ktor-testing.md | MockEngine, API response testing, DI integration |