Pubblicato da Manuel Vivo


Illustrazione di Kiran Puri

Questo articolo descrive come abbiamo eseguito un test di due emissioni consecutive di LiveData mettendo in pausa e riprendendo il CoroutineDispatcher di una Coroutine nell'applicazione Plaid open source.
Non dimenticare di consultare la sezione Buone pratiche alla fine dell'articolo per eseguire test accurati, veloci e affidabili.

Il problema

Volevamo testare due emissioni consecutive di LiveData (una delle quali eseguita in una coroutine), ma non è stato possibile poiché abbiamo iniettato Dispatchers.Unconfined, che ha eseguito immediatamente tutte le coroutine. Per questo motivo, quando siamo arrivati al momento delle asserzioni nel test dell'unità, la prima emissione di LiveData era andata persa e abbiamo potuto verificare solo la seconda emissione. Di seguito sono riportati maggiori dettagli:


Schermata dei dettagli Dribbble Shot in Plaid

Nella schermata dei dettagli Dribbble (una delle fonti dei dati di Plaid), volevamo visualizzare la schermata il più rapidamente possibile, ma l'elaborazione di alcuni elementi poteva richiedere del tempo prima che venissero visualizzati (a causa del markdown di formattazione in Spannables). Per risolvere questo problema, abbiamo deciso di emettere rapidamente una versione semplificata dello stato della UI, quindi di avviare un'operazione in background per produrre la versione elaborata per poi emetterla.
Per questo, usiamo LiveData e Coroutine: LiveData per la comunicazione UI e Coroutine per eseguire operazioni fuori dal thread principale. All'avvio di ViewModel, viene emesso un modello UI di base su LiveData osservato dalla UI. Quindi, chiamiamo il caso d'uso CreateShotUiModel che sposta l'esecuzione in background e crea il modello UI completo. Al termine del caso d'uso, ViewModel emette il modello UI completo sullo stesso LiveData di prima. Tutto ciò è riportato nel codice seguente:
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
        }
    }
}

Consulta il codice completo qui
Vogliamo verificare che entrambi gli stati UI siano stati emessi nella UI. Tuttavia, non abbiamo potuto verificare la prima emissione poiché i due stati della UI sono stati emessi consecutivamente e l'istanza LiveData conteneva solo la seconda emissione. Questo perché la coroutine è stata avviata in processUiModel eseguito in modo sincrono nei nostri test a causa dell'iniezione di Dispatchers.Unconfined.
Plaid usa una classe chiamata CoroutinesDispatcherProvider per iniettare Dispatcher coroutine in classi che funzionano con coroutine.
LiveData conserva solo l'ultimo valore che riceve. Per testare il contenuto di un LiveData nei test, utilizziamo la funzione di estensione LiveData.getOrAwaitValue().
Il test seguente con i nostri requisiti ha esito negativo:
@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())
}

Come possiamo testare questo comportamento?

La soluzione

Abbiamo usato il nuovo TestCoroutineDispatcher dalla libreria delle coroutine (pacchetto kotlinx.coroutines.test) per poter mettere in pausa e riprendere il CoroutineDispatcher della coroutine creata da ViewModel.
Disclaimer: TestCoroutineDispatcher è ancora un'API sperimentale.
Con un'istanza iniettata di TestCoroutineDispatcher, possiamo controllare quando inizia l'esecuzione delle coroutine. La logica del test è la seguente:
  1. Prima dell'inizio del test, mettere in pausa il dispatcher e verificare che sia stato emesso il risultato rapido durante l'inizializzazione di ViewModel.
  2. Riprendere il Dispatcher di test che dà il via alla routine del metodo processUiModel in ViewModel.
  3. Verificare che sia stato emesso il risultato lento.
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())
    }
}

Consulta il codice completo qui
Nota la differenza tra il CoroutinesDispatcherProvider iniettato nei test qui.
Includiamo il corpo del test e le asserzioni all'interno della lambda del corpo testCoroutineDispatcher.runBlockingTest perché, in modo simile a runBlocking, eseguirà le coroutine che utilizzano testCoroutineDispatcher in modo sincrono.
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.

Un approccio alternativo

Esistono altri approcci che puoi scegliere per risolvere questo problema. Abbiamo scelto quello che ritenevamo fosse il migliore per affrontare questo problema, poiché non richiedeva la modifica del codice applicativo.
Un'implementazione alternativa potrebbe implicare l'uso del nuovo generatore di coroutine liveData nel ViewModel per emettere i due elementi e, nei test, utilizzare la funzione di estensione LiveData.asFlow() per l'asserzione di tali elementi, come puoi vedere in questo PR. Questo approccio evita di arrestare il dispatcher nel test e aiuta a disaccoppiare il test dall'implementazione, ma richiede di modificare l'implementazione di ViewModel per utilizzare le API più recenti disponibili nell'estensione di coroutine Lifecycle.

E le buone pratiche

Per rendere i test accurati, veloci e affidabili, è necessario:

Iniettare sempre i Dispatcher!

Non avremmo potuto risolvere il problema se i Dispatcher non fossero stati iniettati nel ViewModel, permettendoci di utilizzare un TestCoroutineDispatcher nei test.
Come buona pratica, inietta sempre Dispatcher nelle classi che li usano. Non dovresti usare direttamente i Dispatcher predefiniti forniti con la libreria delle coroutine (ad es.. Dispatchers.IO) nelle tue classi, poiché ciò rende i test più difficili: passali come dipendenza.
Vederli usati direttamente in qualsiasi classe è un "code smell", come puoi vedere nel codice seguente:
// 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) { … } 
    }
}

Parlando di AAC ViewModels in particolare e dell'utilizzo di viewModelScope che per impostazione predefinita finisce su Dispatchers.Main: poiché il codice viewModelScope non può essere modificato, invece di iniettarlo dovrai eseguire l'override. Eseguiamo questa operazione usando questa regola JUnit con la stessa istanza TestCoroutineDispatcher iniettata nelle altre classi; qui è riportato un esempio. Per saperne di più, leggi questo articolo su viewModelScope.

Inietta TestCoroutineDispatcher anziché Dispatchers.Unconfined

Inietta un'istanza di TestCoroutineDispatcher nelle tue classi e usa il metodo runBlockingTest per eseguire le coroutine che utilizzano tale dispatcher in modo sincrono nei test. Puoi anche usare TestCoroutineDispatcher per poter riprendere e mettere in pausa le coroutine come desideri.
Come regola generale, inietta la stessa istanza di TestCoroutineDispatcher nei tre Dispatcher predefiniti (ovvero Principale, Predefinito, IO). Se devi verificare i tempi delle attività in uno scenario multithread (ad esempio, se desideri testare permutazioni di coroutine in esecuzione contemporaneamente), crea e inietta un'istanza diversa di TestCoroutineDispatcher per ciascun Dispatcher predefinito.

E Dispatchers.Unconfined?

Puoi anche iniettare Dispatcher.Unconfined per il test se desideri eseguire il codice all'interno delle coroutine in modo sincrono (kotlinx-coroutine attualmente lo utilizza per i test). Tuttavia, Unconfined ti offre minori flessibilità di un TestCoroutineDispatcher: non puoi mettere in pausa Unconfined ed è limitato al dispatch immediato.
Inoltre, influirà su ipotesi e timing per il codice che utilizza dispatcher diversi. Ciò è più evidente quando esegui test di calcoli paralleli: ad esempio, questi verranno eseguiti nell'altro in cui sono scritti e non puoi testare le diverse permutazioni dei calcoli che terminano in momenti diversi.
Sia Unconfined sia TestCoroutineDispatcher evitano esplicitamente l'esecuzione parallela. Tuttavia, TestCoroutineDispatcher offre un maggiore controllo sull'ordinamento dell'esecuzione simultanea in modalità pausa, ma non è sufficiente - da solo - per testare ogni permutazione. Qui si applicano i soliti consigli sui test: dovrai progettare il codice tenendo presente la testabilità se stai progettando un comportamento di concorrenza complesso.

Test di LiveData

Per ulteriori best practice sul test di LiveData, consulta il post di Jose Alcerreca.
Vedi il PR Plaid completo per questa modifica qui.