Files
vnidrop/.codex/skills/compose-skill/references/testing.md
Hammed Abass fd4fff2c86 docs: add compose development guidelines and skills
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory.

Co-authored-by: Junie <junie@jetbrains.com>
2026-07-07 16:55:43 +02:00

7.3 KiB

Testing Strategy

What to Test in commonMain

ViewModel Tests with Turbine (highest ROI)

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:

  • Event→state transitions: field edits, validation triggers, loading states
  • Event→effect emissions: navigation, snackbar, error messages
  • Async flows: loading → success, loading → failure, retry
  • Edge cases: empty input, duplicate detection, concurrent saves
  • State preservation: old content kept during refresh, error doesn't wipe data

Testing State and Effects Separately

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)
    }
}

Validation Tests

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.

Calculation Engine Tests

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.

Fake Repositories for ViewModel Tests

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 UI Tests

Compose Multiplatform common UI testing uses runComposeUiTest rather than Android's JUnit TestRule model.

Test:

  • Critical field entry flows
  • Submit enable/disable behavior
  • Error visibility
  • Loading placeholder/content swap
  • Preserved content during refresh
  • Accessibility labels on critical controls

Platform Tests

Android/iOS specific

Test:

  • Platform shell wiring
  • Deep-link entry
  • Navigation host integration
  • Share sheet / clipboard / haptic bindings
  • Platform lifecycle edge cases
  • Keyboard/safe-area regressions

Snapshot Testing Caveats

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.

Lean Default Test Matrix

  1. ViewModel event→state→effect tests for every feature (via Turbine)
  2. Validation/calculation tests for every rule-heavy feature (pure function tests)
  3. UI tests for high-risk screens
  4. Platform integration tests only for real platform behavior

Do not sink weeks into screenshot infrastructure before you have ViewModel test coverage.

Anti-Patterns

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

Domain-Specific Testing

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