Recently, the class StateFlow
was introduced as part of Kotlin coroutines.
I'm currently trying it and encountered an issue while trying to unit test my ViewModel. What I want to achieve: testing that my StateFlow is receiving all the state values in the correct order in my ViewModel.
My code is as follows.
ViewModel:
class WalletViewModel(private val getUserWallets: GetUersWallets) : ViewModel() {
val userWallet: StateFlow<State<UserWallets>> get() = _userWallets
private val _userWallets: MutableStateFlow<State<UserWallets>> =
MutableStateFlow(State.Init)
fun getUserWallets() {
viewModelScope.launch {
getUserWallets.getUserWallets()
.onStart { _userWallets.value = State.Loading }
.collect { _userWallets.value = it }
}
}
My test:
@Test
fun `observe user wallets ok`() = runBlockingTest {
Mockito.`when`(api.getAssetWallets()).thenReturn(TestUtils.getAssetsWalletResponseOk())
Mockito.`when`(api.getFiatWallets()).thenReturn(TestUtils.getFiatWalletResponseOk())
viewModel.getUserWallets()
val res = arrayListOf<State<UserWallets>>()
viewModel.userWallet.toList(res) //doesn't works
Assertions.assertThat(viewModel.userWallet.value is State.Success).isTrue() //works, last value enmited
}
Accessing the last value emitted works. But what I want to test is that all the emitted values are emitted in the correct order.
With this piece of code: viewModel.userWallet.toList(res)
I'm getting the following error:
java.lang.IllegalStateException: This job has not completed yet
at kotlinx.coroutines.JobSupport.getCompletionExceptionOrNull(JobSupport.kt:1189)
at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:53)
at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest$default(TestBuilders.kt:45)
at WalletViewModelTest.observe user wallets ok(WalletViewModelTest.kt:52)
....
I guess I'm missing something obvious. But not sure why as I'm just getting started with coroutines and Flow and this error seems to happen when not using runBlockingTest
, which I use already.
EDIT:
As a temporary solution, I'm testing it as a live data:
@Captor
lateinit var captor: ArgumentCaptor<State<UserWallets>>
@Mock
lateinit var walletsObserver: Observer<State<UserWallets>>
@Test
fun `observe user wallets ok`() = runBlockingTest {
viewModel.userWallet.asLiveData().observeForever(walletsObserver)
viewModel.getUserWallets()
captor.run {
Mockito.verify(walletsObserver, Mockito.times(3)).onChanged(capture())
Assertions.assertThat(allValues[0] is State.Init).isTrue()
Assertions.assertThat(allValues[1] is State.Loading).isTrue()
Assertions.assertThat(allValues[2] is State.Success).isTrue()
}
}
It seems that the Android team changed the API and documentation after this thread. You can check it here: Continuous collection
SharedFlow/StateFlow is a hot flow, and, as described in the docs, A shared flow is called hot because its active instance exists independently of the presence of collectors.
It means the scope that launches the collection of your flow won't complete by itself.
To solve this issue, you need to cancel the scope in which collect
is called, and as the scope of your test is the test itself, it's not ok to cancel the test, so what you need is to launch
it in a different job.
@Test
fun `Testing a integer state flow`() = runTest {
val _intSharedFlow = MutableStateFlow(0)
val intSharedFlow = _intSharedFlow.asStateFlow()
val testResults = mutableListOf<Int>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
intSharedFlow.toList(testResults)
}
_intSharedFlow.value = 5
assertEquals(2, testResults.size)
assertEquals(0, testResults.first())
assertEquals(5, testResults.last())
job.cancel()
}
Improved case: TestScope.backgroundScope ensures that the coroutine gets cancelled before the end of the test.
@Test
fun `Testing an integer state flow`() = runTest {
val _intSharedFlow = MutableStateFlow(0)
val intSharedFlow = _intSharedFlow.asStateFlow()
val testResults = mutableListOf<Int>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
intSharedFlow.toList(testResults)
}
_intSharedFlow.value = 5
assertEquals(2, testResults.size)
assertEquals(0, testResults.first())
assertEquals(5, testResults.last())
}
A few important things:
java.lang.IllegalStateException: This job has not completed yet
StateFlow
, when you start collecting (inside toList
), you receive the last state. But if you first start collecting and then call your function viewModel.getUserWallets()
, the result
list will then have all the states, in case you want to test it too.runTest
API changed a little bit, and we need to use UnconfinedTestDispatcher(testScheduler)
in the context of the launch
call. The documentation says: Notice how UnconfinedTestDispatcher is used for the collecting coroutine here. This ensures that the collecting coroutine is launched eagerly and is ready to receive values after launch returns.