Files
android/.claude/skills/testing-android-code/references/flow-testing-patterns.md
Patrick Honkonen d49629de9e Add Android testing skill for Claude (#6370)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:42:01 +00:00

6.6 KiB

Flow Testing with Turbine

Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.

StateFlow vs EventFlow

StateFlow (Replayed)

Characteristics:

  • replay = 1 - Always emits current value to new collectors
  • First awaitItem() returns the current/initial state
  • Survives configuration changes
  • Used for UI state that needs to be immediately available

Test Pattern:

@Test
fun `action should update state`() = runTest {
    val viewModel = MyViewModel(savedStateHandle, mockRepository)

    viewModel.stateFlow.test {
        // First awaitItem() gets CURRENT state
        assertEquals(INITIAL_STATE, awaitItem())

        // Trigger action
        viewModel.trySendAction(MyAction.LoadData)

        // Next awaitItem() gets UPDATED state
        assertEquals(LOADING_STATE, awaitItem())
        assertEquals(SUCCESS_STATE, awaitItem())
    }
}

EventFlow (No Replay)

Characteristics:

  • replay = 0 - Only emits new events after subscription
  • No initial value emission
  • One-time events (navigation, toasts, dialogs)
  • Does not survive configuration changes

Test Pattern:

@Test
fun `action should emit event`() = runTest {
    val viewModel = MyViewModel(savedStateHandle, mockRepository)

    viewModel.eventFlow.test {
        // MUST call expectNoEvents() first - nothing emitted yet
        expectNoEvents()

        // Trigger action
        viewModel.trySendAction(MyAction.Submit)

        // Now expect the event
        assertEquals(MyEvent.NavigateToNext, awaitItem())
    }
}

Critical: Always call expectNoEvents() before triggering actions on EventFlow. Forgetting this causes flaky tests.

Testing State and Events Simultaneously

Use the stateEventFlow() helper from BaseViewModelTest:

@Test
fun `complex action should update state and emit event`() = runTest {
    val viewModel = MyViewModel(savedStateHandle, mockRepository)

    viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
        // Initial state
        assertEquals(INITIAL_STATE, stateFlow.awaitItem())

        // No events yet
        eventFlow.expectNoEvents()

        // Trigger action
        viewModel.trySendAction(MyAction.ComplexAction)

        // Verify state progression
        assertEquals(LOADING_STATE, stateFlow.awaitItem())
        assertEquals(SUCCESS_STATE, stateFlow.awaitItem())

        // Verify event emission
        assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
    }
}

Repository Flow Testing

Testing Database Flows

@Test
fun `dataFlow should emit when database updates`() = runTest {
    val dataFlow = MutableStateFlow(initialData)
    every { mockDiskSource.dataFlow } returns dataFlow

    repository.dataFlow.test {
        // Initial value
        assertEquals(initialData, awaitItem())

        // Update disk source
        dataFlow.value = updatedData

        // Verify emission
        assertEquals(updatedData, awaitItem())
    }
}

Testing Transformed Flows

@Test
fun `flow transformation should map correctly`() = runTest {
    val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
    every { mockDao.observeUser() } returns sourceFlow

    // Repository transforms entity to domain model
    repository.userFlow.test {
        val expectedUser = User(id = "1", name = "John")
        assertEquals(expectedUser, awaitItem())
    }
}

Common Patterns

Pattern 1: Testing Initial State + Action

@Test
fun `load data should update from idle to loading to success`() = runTest {
    coEvery { repository.getData() } returns "data".asSuccess()

    viewModel.stateFlow.test {
        assertEquals(DEFAULT_STATE, awaitItem())

        viewModel.loadData()

        assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
        assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
    }
}

Pattern 2: Testing Error States

@Test
fun `load data with error should emit failure state`() = runTest {
    val error = Exception("Network error")
    coEvery { repository.getData() } returns error.asFailure()

    viewModel.stateFlow.test {
        assertEquals(DEFAULT_STATE, awaitItem())

        viewModel.loadData()

        assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
        assertEquals(
            DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
            awaitItem(),
        )
    }
}

Pattern 3: Testing Event Sequences

@Test
fun `submit should emit validation then navigation events`() = runTest {
    viewModel.eventFlow.test {
        expectNoEvents()

        viewModel.trySendAction(MyAction.Submit)

        assertEquals(MyEvent.ShowValidation, awaitItem())
        assertEquals(MyEvent.NavigateToNext, awaitItem())
    }
}

Pattern 4: Testing Cancellation

@Test
fun `cancelling collection should stop emissions`() = runTest {
    val flow = flow {
        repeat(100) {
            emit(it)
            delay(100)
        }
    }

    flow.test {
        assertEquals(0, awaitItem())
        assertEquals(1, awaitItem())

        // Cancel after 2 items
        cancel()

        // No more items received
    }
}

Anti-Patterns

Forgetting expectNoEvents() on EventFlow

// WRONG
viewModel.eventFlow.test {
    viewModel.trySendAction(action)  // May fail - no initial expectNoEvents
    assertEquals(event, awaitItem())
}

// CORRECT
viewModel.eventFlow.test {
    expectNoEvents()  // ALWAYS do this first
    viewModel.trySendAction(action)
    assertEquals(event, awaitItem())
}

Not Using runTest

// WRONG - Missing runTest
@Test
fun `test flow`() {
    flow.test { /* ... */ }
}

// CORRECT
@Test
fun `test flow`() = runTest {
    flow.test { /* ... */ }
}

Mixing StateFlow and EventFlow Patterns

// WRONG - Treating StateFlow like EventFlow
stateFlow.test {
    expectNoEvents()  // Unnecessary - StateFlow always has value
    /* ... */
}

// WRONG - Treating EventFlow like StateFlow
eventFlow.test {
    val item = awaitItem()  // Will hang - no initial value!
    /* ... */
}

Reference Implementations

ViewModel with StateFlow and EventFlow: app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt

Repository Flow Testing: data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt

Complex Flow Transformations: data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt