Learn how to test Kotlin Flow, StateFlow and SharedFlow using runTest, virtual time and Turbine. Covers finite and infinite streams, operators, errors, cancellation, loading-success-error states, replay, buffering, lost events and deterministic Flow testing.
Required Dependencies
dependencies {
testImplementation(
"org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>"
)
testImplementation(
"app.cash.turbine:turbine:<version>"
)
}Prefer placing versions in the version catalog.
1. Flow Testing Fundamentals
Kotlin Flow represents a stream that asynchronously emits zero or more values.
fun numbers(): Flow<Int> = flow {
emit(1)
emit(2)
emit(3)
}A Flow test commonly verifies:
- Values emitted
- Emission order
- Initial value
- Completion
- Exceptions
- Cancellation
- Timing
- Transformation operators
- Repeated emissions
- State and event behaviour
@Test
fun numbers_emitsOneTwoThree() = runTest {
val result = numbers().toList()
assertEquals(
listOf(1, 2, 3),
result
)
}Basic testing flow
flowchart LR
S["Flow source"]
O["Operators"]
C["Test collector"]
A["Assertions"]
S --> O --> C --> AMost Flow tests should use runTest so coroutine scheduling and virtual time remain controlled.
2. Cold Flow vs Hot Flow
Cold Flow
A cold Flow starts producing values separately for each collector.
val users: Flow<List<User>> = flow {
emit(repository.getUsers())
}Characteristics:
- Starts when collected
- Each collector triggers a new execution
- Stops when collection stops
- Commonly created with
flow { }
Hot Flow
A hot Flow exists independently of an individual collector.
Examples:
StateFlowSharedFlow
Characteristics:
- Can emit without a specific collector
- May have multiple collectors
- May remain active after one collector stops
- Depending on configuration, new collectors may not receive old values
Comparison
| Point | Cold Flow | Hot Flow |
|---|---|---|
| Starts on collection | Yes | Not necessarily |
| Separate execution per collector | Yes | Usually shared |
| Stores current value | No | StateFlow does |
| Can replay old events | Not normally | SharedFlow can |
| Common use | Repository data pipeline | UI state and events |
| Examples | flow {} | StateFlow, SharedFlow |
3. Testing a Finite Flow
A finite Flow eventually completes.
fun taskUpdates(): Flow<String> = flow {
emit("Loading")
emit("Success")
}Using toList()
@Test
fun taskUpdates_emitsLoadingAndSuccess() = runTest {
val result = taskUpdates().toList()
assertEquals(
listOf("Loading", "Success"),
result
)
}Using Turbine
@Test
fun taskUpdates_emitsAndCompletes() = runTest {
taskUpdates().test {
assertEquals("Loading", awaitItem())
assertEquals("Success", awaitItem())
awaitComplete()
}
}Finite flows are suitable for testing with:
* `toList()`
* `first()`
* `single()`
* `take()`
* Turbine completion assertions4. Testing an Infinite Flow
An infinite Flow does not complete naturally.
Examples:
- StateFlow
- SharedFlow
- Database observation
- Location updates
- WebSocket events
- Polling stream
Do not call `toList()` directly:
val values = infiniteFlow.toList() // May never returnLimit collection with take()
@Test
fun infiniteCounter_collectsFirstThreeValues() = runTest {
val values = infiniteCounter()
.take(3)
.toList()
assertEquals(
listOf(1, 2, 3),
values
)
}Use backgroundScope
@Test
fun sharedFlow_collectsEvents() = runTest {
val values = mutableListOf<Int>()
backgroundScope.launch(
UnconfinedTestDispatcher(testScheduler)
) {
source.collect { values += it }
}
emitter.emit(10)
emitter.emit(20)
assertEquals(listOf(10, 20), values)
}Use Turbine cancellation
state.test {
assertEquals(initialState, awaitItem())
cancelAndIgnoreRemainingEvents()
}5. Collecting Flow Values
Common collection options:
| Method | Purpose |
|---|---|
collect { } | Processes every emitted value from the Flow. |
first() | Returns the first emitted value and then cancels the collection. |
firstOrNull() | Returns the first emitted value or null if no value is emitted. |
single() | Expects exactly one emitted value and throws an error if there are zero or multiple values. |
toList() | Collects all emitted values into a list until the Flow completes. |
take(n) | Limits collection to the first n emitted values. |
Turbine test { } | Allows interactive Flow testing by asserting emissions, completion, and errors. |
Manual collection
@Test
fun flow_collectsValues() = runTest {
val values = mutableListOf<Int>()
numbers().collect {
values += it
}
assertEquals(listOf(1, 2, 3), values)
}Prefer simpler terminal operators when manual collection is unnecessary.
6. Testing with first()
first() returns the first emitted value and cancels the remaining collection.
@Test
fun userFlow_firstValueIsLoading() = runTest {
val firstValue = userFlow().first()
assertEquals(
UserState.Loading,
firstValue
)
}Use `first()` when:- Only the first value matters
- Testing initial repository emission
- Verifying a condition with
first { }
val success = states.first {
it is UserState.Success
}If the Flow completes without emitting, `first()` throws `NoSuchElementException`.
7. Testing with firstOrNull()
`firstOrNull()` returns:- First value when available
nullwhen Flow completes without emitting
@Test
fun emptyFlow_firstOrNullReturnsNull() = runTest {
val result = emptyFlow<String>().firstOrNull()
assertNull(result)
}With a predicate:
val result = values.firstOrNull {
it > 100
}Use it when an empty stream is an acceptable result.
8. Testing with toList()
toList() collects every emission until the Flow completes.
@Test
fun statusFlow_returnsAllStatuses() = runTest {
val result = flowOf(
Status.Loading,
Status.Success
).toList()
assertEquals(
listOf(
Status.Loading,
Status.Success
),
result
)
}Do not use it on a StateFlow, SharedFlow or other infinite stream unless collection is limited:
val values = stateFlow
.take(3)
.toList()9. Testing with take()
take(n) collects the first n values and cancels upstream afterward.
@Test
fun counter_takeThree_returnsThreeValues() = runTest {
val result = counterFlow()
.take(3)
.toList()
assertEquals(
listOf(1, 2, 3),
result
)
}Use take() for:
- Infinite flows
- StateFlow sequences
- Polling results
- Repeated updates
- Cancellation verification
10. Testing Flow Exceptions
Flow that fails
fun failingFlow(): Flow<Int> = flow {
emit(1)
throw IOException("Network unavailable")
}Using Turbine
@Test
fun failingFlow_emitsValueThenError() = runTest {
failingFlow().test {
assertEquals(1, awaitItem())
val error = awaitError()
assertTrue(error is IOException)
assertEquals(
"Network unavailable",
error.message
)
}
}Error mapping with catch
fun safeFlow(): Flow<UiState> {
return repository.load()
.map<DomainData, UiState> {
UiState.Success(it)
}
.catch {
emit(UiState.Error)
}
}@Test
fun repositoryFails_flowEmitsErrorState() = runTest {
val result = safeFlow().toList()
assertEquals(
listOf(UiState.Error),
result
)
}catch handles upstream exceptions, but not arbitrary exceptions thrown downstream by the collector.
11. Testing Flow Cancellation
Flow collection should stop when its collector is cancelled.
@Test
fun cancellingCollector_stopsFlow() = runTest {
var cleanupCalled = false
val source = flow {
try {
emit(1)
awaitCancellation()
} finally {
cleanupCalled = true
}
}
val job = backgroundScope.launch(
UnconfinedTestDispatcher(testScheduler)
) {
source.collect()
}
job.cancelAndJoin()
assertTrue(cleanupCalled)
}Test cancellation for:
- Screen leaving composition
- ViewModel clearing
- Search query changing
flatMapLatest- Timeout
- Lifecycle stopping
- Collector cancellation
Do not catch and suppress CancellationException as an ordinary application failure.
12. Testing Loading, Success and Error Emissions
A common repository or ViewModel Flow emits UI states.
fun loadTasks(): Flow<TaskState> = flow {
emit(TaskState.Loading)
val tasks = repository.getTasks()
emit(
if (tasks.isEmpty()) {
TaskState.Empty
} else {
TaskState.Success(tasks)
}
)
}.catch {
emit(TaskState.Error("Unable to load tasks"))
}Success test
@Test
fun repositorySucceeds_emitsLoadingThenSuccess() = runTest {
repository.tasks = expectedTasks
loadTasks().test {
assertEquals(TaskState.Loading, awaitItem())
assertEquals(
TaskState.Success(expectedTasks),
awaitItem()
)
awaitComplete()
}
}Error test
@Test
fun repositoryFails_emitsLoadingThenError() = runTest {
repository.exception = IOException()
loadTasks().test {
assertEquals(TaskState.Loading, awaitItem())
assertEquals(
TaskState.Error("Unable to load tasks"),
awaitItem()
)
awaitComplete()
}
}13. Testing Repeated Emissions
Verify:
- Number of emissions
- Order
- Duplicate handling
- Timing
- Whether equal values are suppressed
@Test
fun priceFlow_emitsEveryUpdate() = runTest {
flowOf(100, 101, 102).test {
assertEquals(100, awaitItem())
assertEquals(101, awaitItem())
assertEquals(102, awaitItem())
awaitComplete()
}
}To suppress duplicates:
flowOf(1, 1, 2, 2, 3)
.distinctUntilChanged()Expected:
@Test
fun duplicateValues_areRemoved() = runTest {
val result = flowOf(1, 1, 2, 2, 3)
.distinctUntilChanged()
.toList()
assertEquals(listOf(1, 2, 3), result)
}14. Testing Flow Operators
Test meaningful pipelines rather than Kotlin Flow’s own implementation.
Example production pipeline:
fun activeUserNames(): Flow<List<String>> {
return repository.users
.map { users ->
users
.filter { it.isActive }
.map { it.name }
}
}Test:
@Test
fun activeUserNames_returnsOnlyActiveNames() = runTest {
repository.users.emit(
listOf(
User("Mahesh", true),
User("Amit", false)
)
)
val result = activeUserNames().first()
assertEquals(listOf("Mahesh"), result)
}Test your transformation rule—not whether the standard map operator works.
15. Testing map
map transforms every upstream value.
fun pricesWithTax(): Flow<Double> {
return repository.prices.map { price ->
price * 1.18
}
}@Test
fun prices_areMappedWithTax() = runTest {
val result = flowOf(100.0, 200.0)
.map { it * 1.18 }
.toList()
assertEquals(
listOf(118.0, 236.0),
result
)
}Include rounding and boundary rules when they are part of the requirement.
16. Testing filter
filter emits only values satisfying a condition.
@Test
fun positiveAmounts_onlyPositiveValuesRemain() = runTest {
val result = flowOf(-10, 0, 100, 200)
.filter { it > 0 }
.toList()
assertEquals(
listOf(100, 200),
result
)
}Test:
- Matching values
- Non-matching values
- Boundary value
- Empty result
- All values matching
17. Testing combine
combine emits when either source emits after both sources have produced an initial value.
val username = MutableStateFlow("")
val password = MutableStateFlow("")
val isLoginEnabled = combine(
username,
password
) { user, pass ->
user.isNotBlank() && pass.length >= 8
}Test
@Test
fun validUsernameAndPassword_enableLogin() = runTest {
isLoginEnabled.test {
assertFalse(awaitItem())
username.value = "user@example.com"
expectNoEvents()
password.value = "Password123"
assertTrue(awaitItem())
}
}Be careful: depending on scheduling and StateFlow conflation, rapid updates may not expose every intermediate combination.
18. Testing flatMapLatest
flatMapLatest cancels the previous inner Flow when a new upstream value arrives.
Common use case: search.
val searchResults = query
.flatMapLatest { text ->
repository.search(text)
}Test
@Test
fun newQuery_cancelsPreviousSearch() = runTest {
val query = MutableSharedFlow<String>()
fun search(text: String): Flow<String> = flow {
if (text == "Android") {
delay(1_000)
} else {
delay(100)
}
emit(text)
}
val results = query.flatMapLatest(::search)
results.test {
query.emit("Android")
advanceTimeBy(100)
query.emit("Kotlin")
advanceTimeBy(100)
runCurrent()
assertEquals("Kotlin", awaitItem())
advanceUntilIdle()
expectNoEvents()
}
}The old "Android" result should not appear.
19. Testing debounce
debounce waits until no newer value arrives within a specified period.
val debouncedQuery = query.debounce(300)Test
@Test
fun rapidQueries_emitOnlyLatestValue() = runTest {
val query = MutableSharedFlow<String>()
query.debounce(300).test {
query.emit("A")
advanceTimeBy(100)
query.emit("An")
advanceTimeBy(100)
query.emit("Android")
advanceTimeBy(299)
runCurrent()
expectNoEvents()
advanceTimeBy(1)
runCurrent()
assertEquals("Android", awaitItem())
}
}Virtual time prevents a real 300-millisecond wait.
20. Testing Retry Operators
retry retries an upstream Flow when a condition is true.
fun loadWithRetry(): Flow<Data> {
return repository.load()
.retry(2) { error ->
error is IOException
}
}Success after retry
@Test
fun flowFailsTwice_thenSucceeds() = runTest {
var attempts = 0
val source = flow {
attempts++
if (attempts < 3) {
throw IOException()
}
emit("Success")
}.retry(2)
assertEquals(
listOf("Success"),
source.toList()
)
assertEquals(3, attempts)
}Non-retryable error
@Test
fun invalidDataError_isNotRetried() = runTest {
var attempts = 0
val source = flow<String> {
attempts++
throw IllegalArgumentException()
}.retry(3) {
it is IOException
}
source.test {
assertTrue(
awaitError() is IllegalArgumentException
)
}
assertEquals(1, attempts)
}Also test:
- First-attempt success
- Maximum retries exhausted
- Retry delays
- Cancellation during retry
- Retryable vs non-retryable errors
21. StateFlow Fundamentals
StateFlow is a hot Flow that represents current state.
private val _state =
MutableStateFlow<LoginState>(LoginState.Idle)
val state: StateFlow<LoginState> =
_state.asStateFlow()StateFlow characteristics:
- Always has a current value
- Requires an initial value
- New collectors immediately receive the current value
- Does not complete normally
- Suppresses equal consecutive values
- Is suitable for observable UI state
- Exposes current value through
.value
stateDiagram-v2
[*] --> Idle
Idle --> Loading: Login
Loading --> Success: Accepted
Loading --> Error: Failed
Error --> Loading: Retry22. Testing StateFlow’s Initial Value
The simplest approach is checking .value.
@Test
fun initialState_isIdle() {
val viewModel = LoginViewModel(repository)
assertEquals(
LoginState.Idle,
viewModel.state.value
)
}Using Turbine:
@Test
fun collectorImmediatelyReceivesInitialState() = runTest {
viewModel.state.test {
assertEquals(
LoginState.Idle,
awaitItem()
)
cancelAndIgnoreRemainingEvents()
}
}Because StateFlow immediately emits its current value, account for the initial item in Turbine tests.
23. Testing StateFlow State Updates
Final state
@Test
fun loginSucceeds_stateBecomesSuccess() = runTest {
repository.result = expectedUser
viewModel.login()
advanceUntilIdle()
assertEquals(
LoginState.Success(expectedUser),
viewModel.state.value
)
}Full transition
@Test
fun login_emitsLoadingThenSuccess() = runTest {
viewModel.state.test {
assertEquals(LoginState.Idle, awaitItem())
viewModel.login()
assertEquals(LoginState.Loading, awaitItem())
assertEquals(
LoginState.Success(expectedUser),
awaitItem()
)
}
}Use .value when only the final state matters. Use collection when emission order matters.
24. StateFlow Emission Conflation
StateFlow is conflated.
If values change rapidly:
0 → 1 → 2 → 3A slow collector may observe:
0 → 3It is not guaranteed to observe every intermediate value.
StateFlow also suppresses equal values:
state.value = Loading
state.value = LoadingThe second equal assignment normally does not create a new emission.
Test final state when intermediates are unimportant
viewModel.refresh()
advanceUntilIdle()
assertEquals(
UiState.Success(expected),
viewModel.state.value
)If every value is important, use a different mechanism such as:- SharedFlow
- Channel
- Event log
- Non-conflated data structure
25. SharedFlow Fundamentals
SharedFlow is a configurable hot stream used to broadcast values to multiple collectors.
private val _events =
MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1
)
val events = _events.asSharedFlow()Common use cases:
- Navigation
- Snackbar requests
- Analytics events
- Refresh events
- System events
SharedFlow configuration:
| Setting | Purpose |
|---|---|
replay | Values delivered to new collectors |
extraBufferCapacity | Extra room for slow active collectors |
onBufferOverflow | Behaviour when the buffer is full |
Unlike StateFlow, SharedFlow:
- Does not require an initial value
- Does not expose
.value - Can emit equal values repeatedly
- Does not complete normally
26. Testing SharedFlow Events
Start collecting before emitting when replay = 0.
@Test
fun taskClicked_emitsNavigationEvent() = runTest {
viewModel.events.test {
viewModel.onTaskClicked(10L)
assertEquals(
UiEvent.NavigateToTask(10L),
awaitItem()
)
}
}For manual collection:
@Test
fun sharedFlow_emitsEvent() = runTest {
val events = mutableListOf<UiEvent>()
backgroundScope.launch(
UnconfinedTestDispatcher(testScheduler)
) {
viewModel.events.collect {
events += it
}
}
viewModel.onTaskClicked(10L)
assertEquals(
listOf(UiEvent.NavigateToTask(10L)),
events
)
}27. Replay and Buffer Behaviour
Replay
val events = MutableSharedFlow<Int>(
replay = 2
)A new collector receives the last two retained values.
@Test
fun replayTwo_newCollectorReceivesLastTwoValues() = runTest {
val events = MutableSharedFlow<Int>(replay = 2)
events.emit(1)
events.emit(2)
events.emit(3)
events.test {
assertEquals(2, awaitItem())
assertEquals(3, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}Extra buffer
MutableSharedFlow<Event>(
replay = 0,
extraBufferCapacity = 1
)The extra buffer helps producers avoid suspension when active collectors are temporarily slow.
Important:
When there are no subscribers, SharedFlow retains only replayed values. extraBufferCapacity does not make non-replayed events available to future subscribers.
28. Testing Lost Events
With replay = 0, an event emitted before subscription is normally lost for the future collector.
@Test
fun eventEmittedBeforeSubscription_isNotReplayed() = runTest {
val events = MutableSharedFlow<String>(
replay = 0
)
events.emit("Navigate")
events.test {
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}With replay:
@Test
fun replayOne_preservesLatestEventForNewCollector() = runTest {
val events = MutableSharedFlow<String>(
replay = 1
)
events.emit("Navigate")
events.test {
assertEquals("Navigate", awaitItem())
cancelAndIgnoreRemainingEvents()
}
}Be careful with replaying navigation events: a recreated screen might navigate again. Event-delivery strategy should match the product requirement.
29. Turbine Fundamentals
Turbine is a testing library for Flow.
flow.test {
assertEquals(expected, awaitItem())
awaitComplete()
}Important Turbine APIs:
| API | Purpose |
|---|---|
awaitItem() | Waits for and returns the next emitted item from the Flow. |
awaitError() | Waits for a terminal error emitted by the Flow. |
awaitComplete() | Waits for the Flow to complete normally. |
expectNoEvents() | Verifies that no events are immediately available. |
cancel() | Stops the Turbine collection. |
cancelAndIgnoreRemainingEvents() | Stops collection and ignores any pending unconsumed events. |
cancelAndConsumeRemainingEvents() | Stops collection and returns all remaining pending events. |
testIn(scope) | Creates a Turbine collector within a provided coroutine scope. |
turbineScope { } | Coordinates and manages multiple Turbine collectors together. |
Turbine fails when events remain unconsumed unless they are cancelled or handled appropriately.
30. awaitItem()
awaitItem() waits for the next emitted item.
@Test
fun flowEmitsExpectedUser() = runTest {
userFlow.test {
val user = awaitItem()
assertEquals("Mahesh", user.name)
assertEquals(101L, user.id)
awaitComplete()
}
}For StateFlow, the first `awaitItem()` is usually its current value.
Do not assume an emission without accounting for the initial StateFlow item.
31. awaitError()
awaitError() waits for a terminal Flow failure.
@Test
fun flowFailsWithIOException() = runTest {
flow<Int> {
throw IOException("No network")
}.test {
val error = awaitError()
assertTrue(error is IOException)
assertEquals("No network", error.message)
}
}Do not call awaitError() if the Flow catches its error and emits an error state. In that case, use awaitItem().
32. awaitComplete()
awaitComplete() verifies normal Flow completion.
@Test
fun finiteFlow_emitsAndCompletes() = runTest {
flowOf(1, 2).test {
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
awaitComplete()
}
}StateFlow and SharedFlow normally do not complete, so cancel their Turbine collectors instead.
33. expectNoEvents()
expectNoEvents() verifies that no event is currently waiting.
@Test
fun queryBeforeDebounce_doesNotEmit() = runTest {
debouncedQueries.test {
query.emit("Android")
advanceTimeBy(299)
runCurrent()
expectNoEvents()
}
}It is useful for:
- Debounce
- Throttle
- Invalid user action
- Duplicate-event prevention
- SharedFlow replay checks
It checks the current event queue; it does not prove that no event will ever arrive later.
34. Cancelling Turbine Collection
Infinite streams must be cancelled.
@Test
fun stateFlow_hasInitialState() = runTest {
viewModel.state.test {
assertEquals(UiState.Idle, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}Options:
cancel()cancelAndIgnoreRemainingEvents()val remaining =
cancelAndConsumeRemainingEvents()Use cancelAndConsumeRemainingEvents() when pending events themselves need inspection.
35. Testing Multiple Flows
A ViewModel may expose:
- StateFlow for state
- SharedFlow for events
@Test
fun saveTask_updatesStateAndEmitsEvent() = runTest {
turbineScope {
val stateTurbine =
viewModel.state.testIn(backgroundScope)
val eventTurbine =
viewModel.events.testIn(backgroundScope)
assertEquals(
TaskState.Idle,
stateTurbine.awaitItem()
)
viewModel.saveTask("Flow Testing")
assertEquals(
TaskState.Loading,
stateTurbine.awaitItem()
)
assertEquals(
TaskState.Success,
stateTurbine.awaitItem()
)
assertEquals(
UiEvent.ShowMessage("Task saved"),
eventTurbine.awaitItem()
)
stateTurbine.cancelAndIgnoreRemainingEvents()
eventTurbine.cancelAndIgnoreRemainingEvents()
}
}Start all collectors before performing the action to avoid missing events.
36. Deterministic Flow Testing
A deterministic Flow test produces the same result every time.
Use controlled components
runTest- Shared
TestCoroutineScheduler - Test dispatchers
- Virtual time
- Fake repositories
- Fixed input data
- Injected clock
- Controlled Flow emissions
- Explicit collector startup
- Proper cancellation
flowchart TD
F["Fake source"]
TS["Test scheduler"]
VT["Virtual time"]
C["Active collector"]
A["Stable assertions"]
F --> A
TS --> A
VT --> A
C --> AChecklist
1. Use runTest.
2. Avoid real network and database dependencies.
3. Avoid Thread.sleep().
4. Share one test scheduler.
5. Start SharedFlow collectors before emission.
6. Limit infinite streams.
7. Cancel long-running collectors.
8. Control debounce and retry time virtually.
9. Assert order only when order matters.
10. Account for StateFlow conflation.
11. Use fixed data instead of random values.
12. Keep every test independent.
37. Flow-Testing Common Mistakes
1. Calling toList() on an infinite Flow
2. Collecting StateFlow without cancelling
3. Forgetting StateFlow’s initial value
4. Expecting StateFlow to emit equal values repeatedly
5. Assuming StateFlow delivers every intermediate value
6. Starting a SharedFlow collector after emitting an event
7. Believing extraBufferCapacity replays events to future collectors
8. Using replay for navigation without considering duplicate navigation
9. Using Thread.sleep() for debounce
10. Using real coroutine dispatchers
11. Creating multiple unrelated test schedulers
12. Forgetting runCurrent() at a virtual-time boundary
13. Leaving Turbine events unconsumed
14. Calling awaitComplete() on StateFlow
15. Calling awaitError() when errors are mapped into state
16. Forgetting to cancel Turbine collection
17. Suppressing CancellationException18. Testing Flow library implementation instead of business transformations
19. Assuming emission order when operations are concurrent
20. Not testing empty or error emissions
21. Retrying every exception
22. Ignoring maximum retry exhaustion
23. Collecting multiple flows sequentially and missing an event
24. Making tests depend on real clock time
25. Using broad timeouts to hide synchronization defects
Complete Use Case: Search Screen
Production behaviour
flowchart TD
Q["Query StateFlow"]
D["Debounce 300 ms"]
V["Filter valid query"]
F["flatMapLatest"]
R["Repository search"]
S["Result StateFlow"]
E["Error SharedFlow"]
Q --> D --> V --> F --> R
R --> S
R --> ERecommended tests
| Scenario | Expected result |
|---|---|
| Initial state | Empty/idle state |
| Blank query | No repository call |
| Query before 300 ms | No search |
| Query after 300 ms | Search starts |
| Rapid queries | Only latest query processed |
| Old request completes later | Old result ignored |
| Repository succeeds | Success state |
| Empty response | Empty state |
| Repository fails | Error state/event |
| Same state assigned twice | No duplicate StateFlow emission |
| Collector subscribes late | Only configured replay received |
| Screen stops | Collection cancelled |
Interview Questions and Answers
1. What is Kotlin Flow?
Flow is a coroutine-based stream that asynchronously emits zero or more values.
2. What should a Flow test verify?
Values, order, completion, errors, cancellation, timing and business transformations.
3. What is a cold Flow?
A cold Flow starts separately for each collector and normally does not execute until collected.
4. What is a hot Flow?
A hot Flow exists independently of a single collector and may broadcast to multiple collectors.
5. Give examples of hot flows.
StateFlow and SharedFlow.
6. How do you test a finite Flow?
Use toList(), first(), single() or Turbine and verify completion.
7. How do you test an infinite Flow?
Limit it with take(), collect it in backgroundScope, or use Turbine and cancel the collection.
8. Why should toList() not be used directly with StateFlow?
StateFlow does not complete normally, so toList() may wait forever.
9. What does first() do?
It returns the first emitted value and cancels collection afterward.
10. What happens when first() is used on an empty Flow?
It throws NoSuchElementException.
11. What does firstOrNull() do?
It returns the first value or null if the Flow completes without emitting.
12. What does take(n) do?
It collects the first n values and cancels upstream afterward.
13. How do you test Flow exceptions?
Use Turbine’s awaitError() or assert an error state when the exception is caught and mapped.
14. How do you test Flow cancellation?
Cancel the collector and verify that upstream cleanup runs and no further work occurs.
15. Should CancellationException be mapped to a general UI error?
Normally no. It represents cooperative cancellation and should generally be rethrown.
16. How do you test loading, success and error emissions?
Configure the dependency, collect the Flow and assert the complete emission sequence.
17. How do you test repeated emissions?
Collect a limited sequence and assert count, order and duplicate behaviour.
18. Should standard Flow operators themselves be tested?
No. Test the application’s business transformation built with those operators.
19. What does map do?
It transforms each upstream value into another value.
20. What does filter do?
It emits only values that satisfy a condition.
21. What does combine do?
It combines the latest values from multiple flows after each has emitted at least once.
22. What does flatMapLatest do?
It cancels the previous inner Flow when a new upstream value arrives.
23. Where is flatMapLatest useful?
Search, autocomplete and any operation where only the latest request matters.
24. How do you test debounce?
Use virtual time, emit values rapidly and verify that only the final value appears after the debounce period.
25. How do you test retry?
Count attempts and verify success, exhausted retries, accepted exception types and retry delays.
26. What is StateFlow?
StateFlow is a hot, conflated Flow that always contains a current value.
27. Does StateFlow require an initial value?
Yes.
28. How do you test StateFlow’s initial state?
Read .value or use Turbine’s first awaitItem().
29. When should .value be used in a test?
When only the current or final state matters.
30. When should StateFlow be collected in a test?
When emission sequence or intermediate states matter.
31. What is StateFlow conflation?
Rapid updates may be combined so a slow collector observes only the latest state.
32. Does StateFlow emit two equal consecutive values?
Normally no. Equality-based conflation suppresses an equal update.
33. Should StateFlow be used for every event?
No. It is intended for current state, not every one-time event.
34. What is SharedFlow?
SharedFlow is a configurable hot stream that broadcasts values to multiple collectors.
35. SharedFlow vs StateFlow?
StateFlow has a current value and an initial state. SharedFlow has configurable replay and does not require an initial value.
36. What does SharedFlow replay do?
It stores a configured number of recent values and delivers them to new collectors.
37. What does extraBufferCapacity do?
It provides extra room for emissions when active collectors are slow.
38. Does extraBufferCapacity preserve events for future subscribers?
No. Without subscribers, only replayed values are retained for later collectors.
39. Why can a SharedFlow event be lost?
With replay = 0, an event emitted before subscription is not delivered to that later subscriber.
40. How do you test a SharedFlow event?
Start collection first, trigger the action and assert the next item.
41. What is Turbine?
Turbine is a Kotlin Flow-testing library that provides APIs for awaiting items, completion and errors.
42. What does awaitItem() do?
It waits for and returns the next emitted item.
43. What does awaitError() do?
It waits for and returns a terminal Flow exception.
44. What does awaitComplete() do?
It verifies that a Flow completes normally.
45. What does expectNoEvents() do?
It verifies that no event is currently available in the Turbine event queue.
46. Why cancel a Turbine collector?
StateFlow, SharedFlow and other infinite streams do not complete naturally.
47. How do you test multiple flows together?
Start a Turbine collector for each Flow before triggering the action, then assert state and events independently.
48. What makes a Flow test deterministic?
Controlled inputs, fake dependencies, virtual time, one scheduler, active collectors and proper cancellation.
Quick Revision
| Concept / API | Purpose |
|---|---|
| Cold Flow | Starts execution separately for each collector. Each collector gets an independent flow execution. |
| StateFlow | A hot Flow that always contains an initial/current value and is mainly used to represent persistent UI state. |
| StateFlow Conflation | Keeps only the latest state value by skipping intermediate rapid updates; duplicate equal values are not emitted. |
| SharedFlow | A hot Flow designed for events; it does not require an initial value and supports configurable replay and buffering. |
first() | Returns the first emitted value and throws an exception if the Flow completes without emitting any value. |
firstOrNull() | Returns the first emitted value or null when no value is emitted. |
toList() | Collects all emitted values until the Flow completes and returns them as a list. |
take(n) | Collects only the first n emitted values and then automatically cancels the collection. |
awaitItem() (Turbine) | Waits for and returns the next emitted Flow value during testing. |
awaitError() (Turbine) | Waits for a terminal exception/error emitted by the Flow. |
awaitComplete() (Turbine) | Waits for the Flow to complete normally. |
expectNoEvents() (Turbine) | Verifies that no events are currently queued or available. |
| Infinite Flow Handling | Prevents hanging tests by limiting collection with operators like take(), cancelling manually, or collecting in backgroundScope. |
Flow Comparison
| Feature | Cold Flow | StateFlow | SharedFlow |
|---|---|---|---|
| Execution | Starts per collector | Starts immediately and stays active | Starts immediately and stays active |
| Initial Value | Not required | Required | Not required |
| Current Value | ❌ No | ✅ Yes (value) | ❌ No |
| Use Case | Data streams, async operations | UI state management | One-time events, broadcasts |
| Replay Support | ❌ No | Latest value always available | Configurable using replay |
| Multiple Collectors | Separate execution | Share same state | Share same emissions |