I have LoginViewModel which contains Compose state backed field for email and password. I want to create a StateFlow for enabling/disabling the Login button. I use combine
and snapshotFlow
, and stateIn
for that. But the issue is only initialvalue
is emitted. It looks like the snapshotFlow does not get triggered when there is change in compose backed state. Is it expected or am i missing something here?
@Test
fun testStateFlow() = runTest {
var emailState by mutableStateOf("")
var passwordState by mutableStateOf("")
val allInputValid: StateFlow<Boolean> = combine(
snapshotFlow { emailState },
snapshotFlow { passwordState }
) { emailValue, passwordValue ->
emailValue.isNotEmpty() && passwordValue.isNotEmpty()
}.stateIn(
CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
SharingStarted.WhileSubscribed(5000),
false
)
val test = mutableListOf<String>()
val job = launch {
allInputValid.collect {
test.add("Value emitted: $it")
println(it)
}
}
advanceUntilIdle()
emailState = "test_email"
passwordState = "test_password"
advanceUntilIdle()
println(test)
job.cancel()
}
When you set emailState = "test_email"
, you expect the snapshotFlow to detect the changed State. That can only work, however, when the change can be observed by the snapshot system. In your production code that works because the changes originate from compose functions that run in such a snapshot context. Isolated in a unit test, there is nothing there to detect the changes, so the snapshotFlow never gets notified.
You can always create such a context ad-hoc (not recommended, see below):
Snapshot.withMutableSnapshot {
emailState = "test_email"
passwordState = "test_password"
}
This would need to be done in your view model as well: Since the view model can be used outside of a compose context (as in your unit test), everywhere where the view model changes a MutableState it should be wrapped with a Snapshot.withMutableSnapshot
.
This is tedious and error prone, so the recommended way to handle this is to drop all MutableState from your view model alltogether.
Instead, use a MutableStateFlow
(*). It can be used similar to a MutableState: Change its value by setting its value
property, and since it already is a flow it can be easily combined with other flows.
The test from your question would look like this, completely bypassing the problematic MutableState:
@Test
fun testStateFlow() = runTest {
val emailState = MutableStateFlow("")
val passwordState = MutableStateFlow("")
val allInputValid: StateFlow<Boolean> = combine(
emailState,
passwordState,
) { emailValue, passwordValue ->
emailValue.isNotEmpty() && passwordValue.isNotEmpty()
}.stateIn(
CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
SharingStarted.WhileSubscribed(5000),
false,
)
val test = mutableListOf<String>()
val job = launch {
allInputValid.collect {
test.add("Value emitted: $it")
println(it)
}
}
advanceUntilIdle()
emailState.value = "test_email"
passwordState.value = "test_password"
advanceUntilIdle()
println(test)
job.cancel()
}
(*): Although the names MutableState
and MutableStateFlow
look so similar they have nothing in common except some of their semantics. MutableState
is a type of the Compose framework from Google, where MutableStateFlow
is a type of the Flow framework that is an integral part of the Kotlin language itself.