Not sure if this really is Compose-specific (maybe...
# compose
s
Not sure if this really is Compose-specific (maybe I don't understand kotlinx.coroutines-test properly), but how would I go about unit testing that a separate state updates as a result of
TextFieldState
changes? I was hoping I could do something like what I have in the thread.
Copy code
class LoginViewModel : ViewModel() {
    init {
        viewModelScope.launch {
            combine(
                snapshotFlow { uiState.username.text },
                snapshotFlow { uiState.password.text },
            ) { username, password -> username.isNotBlank() && password.isNotBlank() }
                .distinctUntilChanged()
                .collect { isFormFilled ->
                    uiState = uiState.copy(
                        signInButtonState = if (isFormFilled) {
                            SignInButtonState.Enabled
                        } else {
                            SignInButtonState.Disabled
                        },
                    )
                }
        }
    }
}
Copy code
@Test
fun `If the form is filled, the Sign In button is enabled`() = runTest {
    val viewModel: LoginViewModel = // ...

    viewModel.uiState.username.edit { insert(0, "AzureDiamond") }
    viewModel.uiState.password.edit { insert(0, "hunter2") }

    advanceUntilIdle()

    assertEquals(SignInButtonState.Enabled, viewModel.uiState.signInButtonState)
}
s
Is there a reason you don't calculate that when it's needed? You could add in the uiState:
val signInButtonState get() = if (username.isNotBlank() && password.isNotBlank()) SignInButtonState.Enabled else SignInButtonState.Disabled
s
I could do that, but there are other states, too (a
SignInButtonState.Loading
, for instance, that would need to be set outside.
s
Can you pass into your ViewModel a custom CoroutineScope which doesn't require you to do all the dance with advanceUntilIdle etc. Inside the scope of your runTest{} lambda you got access to
backgroundScope
and you can pass that into the constructor of your ViewModel as of the later versions. That way you can at least take the ViewModel quirks out of the way.
s
Ideally, yes, but it complicates our current setup with Hilt how these ViewModels are created in production code.
Just did it for the sake of checking if the test would pass, and interestingly, now all my other tests there fail. 😅
😅 1
s
Does it? Can you try to default to whatever you were defaulting to before as well, but leave it open for overriding for tests?
s
Copy code
internal class LoginViewModel @Inject constructor(
    // ...
    coroutineSScope: CoroutineScope,
) : ViewModel(coroutineSScope) {
    // still using this.viewModelScope in the class body
}
Defaulting to the
ViewModel.viewModelScope
extension doesn't seem straight-forward, either.
(That's not just an IDE bug, it also doesn't compile.)
s
Yeah that doesn't work I think. But you can default to what viewModelScope did internally, which was
Dispatchers.main.immediate + SupervisorScope()
a
You need this for
snapshotFlow
to work in unit tests.
🤯 1
s
Yeah, setting the default to what ViewModel does internally does work, but it feels a little bad. If it turns out that this setup is buggy and gets fixed internally in androidx, I would need to follow up on those fixes in my own code, too.
Oh, thanks, @Albert Chang! I'll try that out!
That did the trick:
Copy code
@Test
fun `If the form is filled, the Sign In button should be enabled`() = runTest {
    val viewModel = LoginViewModel(
        repository = FakeAuthRepository(loginResult = Unit.right()),
        tracker = fakeTracker,
        networkConfigRepository = FakeNetworkConfigRepository(),
    )

    viewModel.uiState.username.edit { insert(0, "AzureDiamond") }
    viewModel.uiState.password.edit { insert(0, "hunter2") }

    Snapshot.sendApplyNotifications()

    assertEquals(SignInButtonState.Enabled, viewModel.uiState.signInButtonState)
}
Thanks!
Although why wouldn't I also need
advanceUntilIdle()
here? 🤔