Recientemente se introdujo la clase StateFlow como parte de la rutina de Kotlin.
Actualmente lo estoy probando y encuentro un problema al intentar realizar una prueba unitaria de mi ViewModel. Lo que quiero lograr: probar que mi StateFlow está recibiendo todos los valores de estado en el orden correcto en mi ViewModel.
Mi código es el siguiente:
Ver modelo:
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 } } }Mi prueba:
@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 } Acceder al último valor emitido funciona. Pero lo que quiero probar es que todos los valores emitidos se emiten en el orden correcto. con este fragmento de código: viewModel.userWallet.toList(res) //doesn't works Recibo el siguiente 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) ....Supongo que me estoy perdiendo algo obvio. Pero no estoy seguro de por qué, ya que recién estoy comenzando con Coroutine y Flow y este error parece ocurrir cuando no uso runBlockingTest, que ya uso.
EDITAR: como solución temporal, lo estoy probando como datos en vivo:
@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() } }runBlockingTest simplemente omite los retrasos en su caso, pero no anula el despachador utilizado en ViewModel con su despachador de prueba. Debe inyectar TestCoroutineDispatcher en su ViewModel o, dado que está usando viewModelScope.launch {} , que ya usa Dispatchers.Main de forma predeterminada, debe anular el despachador principal a través de Dispatchers.setMain(testCoroutineDispatcher) . Puede crear y agregar la siguiente regla a su archivo de prueba.
class MainCoroutineRule( val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() ) : TestWatcher() { override fun starting(description: Description?) { super.starting(description) Dispatchers.setMain(testDispatcher) } override fun finished(description: Description?) { super.finished(description) Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } }Y en su archivo de prueba
@get:Rule var mainCoroutineRule = MainCoroutineRule() @Test fun `observe user wallets ok`() = mainCoroutineRule.testDispatcher.runBlockingTest { } Por cierto, siempre es una buena práctica inyectar despachadores. Por ejemplo, si hubiera estado usando un despachador que no sea Dispatchers.Main en su ámbito de rutina como viewModelScope.launch(Dispatchers.Default) , entonces su prueba fallará nuevamente incluso si está usando un despachador de prueba. El motivo es que solo puede anular el despachador principal con Dispatchers.setMain() , ya que puede entenderse por su nombre, pero no Dispatchers.IO o Dispatchers.Default . En ese caso, debe inyectar mainCoroutineRule.testDispatcher en su modelo de vista y usar el despachador inyectado en lugar de codificarlo.
El problema al que se enfrenta es porque toList() necesita que el flujo se complete y "el flujo de estado nunca se completa" según la documentación.
SharedFlow/StateFlow es un flujo activo y, como se describe en los documentos, A shared flow is called hot because its active instance exists independently of the presence of collectors. Significa que el alcance que inicia la colección de su flujo no se completará solo.
Para resolver este problema, debe cancelar el alcance en el que se llama a la recopilación, y como el alcance de su prueba es la prueba en sí, no está bien cancelar la prueba, por lo que lo que necesita es iniciarlo en un trabajo diferente.
@Test fun `Testing a integer state flow`() = runBlockingTest{ val _intSharedFlow = MutableStateFlow(0) val intSharedFlow = _intSharedFlow.asStateFlow() val testResults = mutableListOf<Int>() val job = launch { intSharedFlow.toList(testResults) } _intSharedFlow.value = 5 assertEquals(2, testResults.size) assertEquals(0, testResults.first()) assertEquals(5, testResults.last()) job.cancel() }Su caso de uso específico:
@Test fun `observe user wallets ok`() = runBlockingTest { whenever(api.getAssetWallets()).thenReturn(TestUtils.getAssetsWalletResponseOk()) whenever(api.getFiatWallets()).thenReturn(TestUtils.getFiatWalletResponseOk()) viewModel.getUserWallets() val result = arrayListOf<State<UserWallets>>() val job = launch { viewModel.userWallet.toList(result) //now it should work } Assertions.assertThat(viewModel.userWallet.value is State.Success).isTrue() //works, last value enmited Assertions.assertThat(result.first() is State.Success) //also works job.cancel() }Dos cosas importantes:
java.lang.IllegalStateException: This job has not completed yettoList ), recibe el último estado. Pero si primero comienza a recopilar y después de llamar a su función viewModel.getUserWallets() , dentro de la lista de result , tendrá todos los estados, en caso de que quiera probarlo también.Podemos crear una corrutina para dado y una corrutina para siempre
después de cualquier código, podemos usar el rendimiento para que nuestro código dado esté listo y listo para afirmar.
para hacer eso, necesita extender CouroutinScope como puede ver:
¡hecho!