class ShotViewModel(...) { init { val result = shotsRepository.getShot(shotId) if (result is Result.Success) { // FIRST UI EMISSION with an incomplete UI Model _shotUiModel.value = result.data.toShotUiModel() processUiModel(result.data) } else { ... } } private fun processUiModel(shot: Shot) { viewModelScope.launch(dispatcherProvider.main) { // Processing full model in the background // The createShotUseCase makes this call main-safe val uiModel = createShotUiModel(shot) // SECOND UI EMISSION with the full UI Model _shotUiModel.value = uiModel } } }
LiveData conserva solo l'ultimo valore che riceve. Per testare il contenuto di un LiveData nei test, utilizziamo la funzione di estensione LiveData.getOrAwaitValue().
@Test fun loadShot_emitsTwoUiModels() { // When the ViewModel has started val viewModel = ... // Creates viewModel // Then the fast result has been emitted val fastResult = viewModel.shotUiModel.getOrAwaitValue() // THIS FAILS!!! The slow result has already been emitted because the coroutine // was executed immediately and shotUiModel LiveData contains the slow result assertTrue(fastResult.formattedDescription.isEmpty()) // And then, the slow result has been emitted val slowResult = viewModel.shotUiModel.getOrAwaitValue() assertTrue(slowResult.formattedDescription.isNotEmpty()) }
Disclaimer: TestCoroutineDispatcher è ancora un'API sperimentale.
class ShotViewModelTest { // This CoroutineDispatcher is injected in the ViewModel and use case private val testCoroutineDispatcher = TestCoroutineDispatcher() @After fun tearDown() { testCoroutineDispatcher.cleanupTestCoroutines() } @Test fun loadShot_emitsTwoUiModels() = testCoroutineDispatcher.runBlockingTest { // 1) Given coroutines have not started yet and the View Model is created testCoroutineDispatcher.pauseDispatcher() val viewModel = ... // Creates viewModel injecting testCoroutineDispatcher // Then the fast result has been emitted val fastResult = viewModel.shotUiModel.getOrAwaitValue() assertTrue(fastResult.formattedDescription.isEmpty()) // 2) When the coroutine starts testCoroutineDispatcher.resumeDispatcher() // 3) Then the slow result has been emitted val slowResult = viewModel.shotUiModel.getOrAwaitValue() assertTrue(slowResult.formattedDescription.isNotEmpty()) } }
Disclaimer 2: Per evitare di dover configurare e "smontare" un TestCoroutineDispatcher per ogni test, puoi utilizzare questa regola di test JUnit, così come viene usata in altri test.
// Bad practice class MyViewModel() { fun example() { // ------------------ code smell --------- viewModelScope.launch(Dispatchers.IO) { … } } } // Inject Dispatchers as shown below! class MyViewModel(private val ioDispatcher: CoroutineDispatcher): ViewModel() { fun example() { // ------------------ good practice ----- viewModelScope.launch(ioDispatcher) { … } } }