mirror of
https://github.com/bitwarden/android.git
synced 2026-05-03 05:38:34 -05:00
275 lines
6.6 KiB
Markdown
275 lines
6.6 KiB
Markdown
# 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:**
|
|
```kotlin
|
|
@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:**
|
|
```kotlin
|
|
@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`:
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
// WRONG - Missing runTest
|
|
@Test
|
|
fun `test flow`() {
|
|
flow.test { /* ... */ }
|
|
}
|
|
|
|
// CORRECT
|
|
@Test
|
|
fun `test flow`() = runTest {
|
|
flow.test { /* ... */ }
|
|
}
|
|
```
|
|
|
|
### ❌ Mixing StateFlow and EventFlow Patterns
|
|
|
|
```kotlin
|
|
// 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`
|