kotlinkotlin-coroutineskotlin-stateflowkotlinx.coroutines.flow

Flow doesn't emit in test when using Dispatchers.Main.immediate (or viewModelScope) to change the value of a StateFlow


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.


Solution

  • 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).