I investigated for hours and searched the web (and even bothered ChatGPT) and I'm puzzled this hasn't been solved yet to my (obviously very limited) knowledge.
So this is the setup: I have a view model with a StateFlow
that is updated within a launch { ... }
block. In the test code, I can't get the flow to emit anything (except the initial value). Only if I put a yield()
or delay()
call in the production code it correctly emits.
I reduced the example down to it's essence to reproduce the issue (it's runnable by simply copy-pasting):
@ExperimentalCoroutinesApi
class MyTest {
class MyViewModel {
private val _state = MutableStateFlow(false)
val state = _state.asStateFlow()
fun doSomething(runYield: Boolean) {
val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) // btw. this is how Android provides a `viewModelScope`
viewModelScope.launch {
_state.value = true
// some workload here
if (runYield)
yield()
_state.value = false
}
}
}
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `Failing test`() = runTestCase(runYield = false)
@Test
fun `Passing test`() = runTestCase(runYield = true)
private fun runTestCase(runYield: Boolean) = runTest {
val viewModel = MyViewModel()
// Collect emissions to `state` in this mutable list
val testResults = mutableListOf<Boolean>()
// backgroundScope makes sure the job is shut down after the test
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.state.toList(testResults)
}
viewModel.doSomething(runYield)
assertEquals(listOf(false, true, false), testResults)
}
}
Using a MutableSharedFlow
instead of the MutableStateFlow
also mitigates the problem, btw.
It took me a long time to know that a yield()
or delay()
call mitigates the problem. But: why on earth? And how can I fix the problem without it?
Using Kotlin version 1.9.10 and Coroutines core + test library 1.8.1.
You're missing a crucial detail about StateFlow: it behaves as conflated flow with a replay count of 1.
That means only the latest value is guaranteed to be emitted.
By doing "value=true value=false" in succession (without suspension in between) the older value has no opportunity to reach the collector.
As a note: (by default) SharedFlow is not conflated and emit()-ting into it causes suspension that does not end until all collectors handle said value. This is not the case for StateFlow which has unspecified delivery time (as .value can be changed from any thread and does not suspend).