I'm migrating to the new test coroutine API, and m...
# coroutines
a
I'm migrating to the new test coroutine API, and my tests that used to work using pause/resumeDispatcher aren't passing any more, eventhough I'm using
runCurrent
. So the problem, is that I have a ViewModel, that exposes a
StateFlow<MyState
, that's backed by
combine(flow1, flow2...
, I'm trying to assert the intermediate states that should be emitted, yet only the initial and the last state got emitted. Any idea what I'm doing wrong? (code in 🧵)
MyViewModel.kt
Copy code
internal class MyViewModel : ViewModel() {

    private val isLoading = MutableStateFlow(false)
    private val isLoggedIn = MutableStateFlow(false)

    data class MyState(
        val isLoading: Boolean = false,
        val isLoggedIn: Boolean = false
    )

    val state: StateFlow<MyState> by lazy {
        combine(isLoading, isLoggedIn) { isLoading, isLoggedIn ->
            MyState(isLoading, isLoggedIn)
        }.stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(),
            MyState(isLoading = false, isLoggedIn = false)
        ).also { init() }
    }

    private fun init() {
        viewModelScope.launch {
            isLoading.value = true
            isLoading.value = false
            isLoggedIn.value = true
        }
    }
}
And the test class that's failing:
Copy code
internal class CombineTest {


    @Test
    fun `combine should emit all intermediate values when new test api are used`() {
        Dispatchers.setMain(StandardTestDispatcher())

        runTest {
            val viewModel = MyViewModel()

            val emissions = mutableListOf<MyViewModel.MyState>()
            val job = launch {
                viewModel.state.toList(emissions)
            }

            runCurrent()

            assertEquals(
                listOf(
                    // Initial State
                    MyViewModel.MyState(isLoading = false, isLoggedIn = false),
                    // Loading is shown
                    MyViewModel.MyState(isLoading = true, isLoggedIn = false),
                    // Loading is hidden
                    MyViewModel.MyState(isLoading = false, isLoggedIn = false),
                    // We're now logged in
                    MyViewModel.MyState(isLoading = false, isLoggedIn = true)
                ),
                emissions
            )

            job.cancel()
        }

        Dispatchers.resetMain()
    }
}
j
The problem is that you're updating your state in a non-suspending way in
init()
. The coroutine you launch there updates the state by setting
value
, but it doesn't suspend, thus it never releases the current thread (the only thread) and no other coroutines can run in between. If other coroutines could run, then it still wouldn't really be deterministic because you would need to call
runCurrent
between state updates.
a
That's good point, however I tried to use
emit
instead of
value
, that still didn't work, as it turned out to be from reading the source code, that
emit
just internally delegates to
value
, so there's no really a way to make the StateFlow to suspend? Also the thing, is that test was passing in the old apis, yet when moved to the new APIs it started to fail.
j
The thing is, when using a state flow, the API guarantees only the latest event, because it's conflated. So you're not supposed to rely on the fact that it emits intermediate values
a
But isn't this how
combine
works? with every update to a flow that backs it, it emits a new state, based on that updated value, no? So theoritically I should get synchronously a new state every time
.value=
is called?
j
combine
may work this way, but then
stateIn
may conflate events. And no, setting value doesn't perform operations synchronously. From the docs of `StateFlow`:
The value of mutable state flow can be updated by setting its value property. Updates to the value are always conflated. So a slow collector skips fast updates, but always collects the most recently emitted value.
"Slow" here is a relative term. In your case, your coroutine in
init
is a "fast" emitter because it's actually the only running coroutine at that moment, during the whole sequence of state updates. In a sense it has the time to do all the updates before the "slow" other coroutines even have a chance to start collecting the first event.
a
That makes sense, thanks for the explanation. Do you have any suggestions on how to make the test pass? I've tried to make
init
slower, by introducing a
delay
between every
value=
, then in the test I use
advanceUntilIdle
, and that did the trick, and now the test passes, however, I'm curious if there are other approaches?
j
Is
init
like that in production code? IMO it doesn't make sense to set the state value like this twice in a row. This is not what
StateFlow
is for. If you want to model a sequence of events without conflation, you should probably use other kinds of flows. If there is more code in
init
between the
value
assignments, and you indeed want to model a conflated state, then you can of course keep the
StateFlow
. But then, testing this is complicated. I would expect other suspend function calls between the value assignments, and you could mock them in the tests to insert
runCurrent
or things like this.
a
obviously no, that's not production code, production code has a call to network, I was just experimenting, probably in the real test, I'll intorduce a yield when I mock that call, thanks a lot for clearing this up! 🙏
👍 1
n
But isn't this how
combine
works? with every update to a flow that backs it, it emits a new state, based on that updated value, no?
No.
combine
works on "most recently emitted values" per docs and can drop old items. Currently it's implemented so that every update to a
Flow
is sent into a single
Channel
, and another coroutine drains the
Channel
to figure out the latest values of the `Flow`s. If it sees multiple updates from the same
Flow
while draining, all but the last are ignored.