Ahmed Ibrahim
07/05/2022, 12:18 PMrunCurrent
.
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 🧵)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:
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()
}
}
Joffrey
07/05/2022, 12:54 PMinit()
. 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.Ahmed Ibrahim
07/05/2022, 1:04 PMemit
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.Joffrey
07/05/2022, 1:14 PMAhmed Ibrahim
07/05/2022, 1:40 PMcombine
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?Joffrey
07/05/2022, 1:45 PMcombine
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.Ahmed Ibrahim
07/05/2022, 2:39 PMinit
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?Joffrey
07/05/2022, 3:03 PMinit
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.Ahmed Ibrahim
07/05/2022, 3:08 PMNick Allen
07/05/2022, 5:59 PMBut isn't this howNo.works? with every update to a flow that backs it, it emits a new state, based on that updated value, no?combine
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.