mirror of
https://github.com/bitwarden/android.git
synced 2026-05-05 23:38:51 -05:00
Add Android testing skill for Claude (#6370)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user