Test dei flussi Kotlin su Android

Il modo in cui testi unità o moduli che comunicano con flow dipende dal fatto che il soggetto sottoposto a test utilizzi il flusso come input o output.

  • Se il soggetto sottoposto a test osserva un flusso, puoi generare flussi all'interno di dipendenze false che puoi controllare dai test.
  • Se l'unità o il modulo espone un flusso, puoi leggere e verificare uno o più elementi emessi da un flusso nel test.

Creazione di un produttore fittizio

Quando il soggetto sottoposto a test è un consumatore di un flusso, un modo comune per verificarlo è sostituire il producer con un'implementazione falsa. Ad esempio, data una classe che osserva un repository che prende i dati da due origini dati in produzione:

il soggetto sottoposto a test e il livello dati
Figura 1. Il soggetto sottoposto a test e il livello dati.

Per rendere il test deterministico, puoi sostituire il repository e le sue dipendenze con un repository falso che emette sempre gli stessi dati falsi:

le dipendenze vengono sostituite da un'implementazione falsa
Figura 2. Le dipendenze vengono sostituite da un'implementazione falsa.

Per emettere una serie predefinita di valori in un flusso, utilizza lo strumento di creazione flow:

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

Nel test, questo falso repository viene inserito, sostituendo la reale implementazione:

@Test
fun myTest() {
    // Given a class with fake dependencies:
    val sut = MyUnitUnderTest(MyFakeRepository())
    // Trigger and verify
    ...
}

Ora che hai il controllo sugli output dell'oggetto sottoposto a test, puoi verificare che funzioni correttamente controllando i suoi output.

Dichiarare le emissioni del flusso in un test

Se il soggetto sottoposto a test sta esponendo un flusso, il test deve effettuare asserzioni sugli elementi dello stream di dati.

Supponiamo che il repository dell'esempio precedente espone un flusso:

repository con dipendenze false che espongono un flusso
Figura 3. Un repository (il soggetto in fase di test) con false dipendenze che espone un flusso.

Con alcuni test, dovrai controllare solo la prima emissione o un numero finito di elementi provenienti dal flusso.

Puoi consumare la prima emissione nel flusso chiamando first(). Questa funzione attende la ricezione del primo articolo, quindi invia l'indicatore di annullamento al producer.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository that combines values from two data sources:
    val repository = MyRepository(fakeSource1, fakeSource2)

    // When the repository emits a value
    val firstItem = repository.counter.first() // Returns the first item in the flow

    // Then check it's the expected item
    assertEquals(ITEM_1, firstItem)
}

Se il test deve verificare più valori, la chiamata a toList() fa sì che il flusso attenda che l'origine emetta tutti i propri valori, poi li restituisca sotto forma di elenco. Questa opzione funziona solo per stream di dati limitati.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository with a fake data source that emits ALL_MESSAGES
    val messages = repository.observeChatMessages().toList()

    // When all messages are emitted then they should be ALL_MESSAGES
    assertEquals(ALL_MESSAGES, messages)
}

Per gli stream di dati che richiedono una raccolta più complessa di elementi o che non restituiscono un numero finito di elementi, puoi utilizzare l'API Flow per scegliere e trasformare gli elementi. Ecco alcuni esempi:

// Take the second item
outputFlow.drop(1).first()

// Take the first 5 items
outputFlow.take(5).toList()

// Takes the first item verifying that the flow is closed after that
outputFlow.single()

// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)

Raccolta continua durante un test

La raccolta di un flusso con toList() come mostrato nell'esempio precedente utilizza collect() internamente e viene sospeso finché l'intero elenco dei risultati non è pronto per essere restituito.

Per interfoliare le azioni che causano l'emissione da parte del flusso di valori e asserzioni sui valori emessi, puoi raccogliere continuamente valori da un flusso durante un test.

Ad esempio, prendi la seguente classe Repository da testare e un'implementazione di un'origine dati falsa con un metodo emit per produrre valori in modo dinamico durante il test:

class Repository(private val dataSource: DataSource) {
    fun scores(): Flow<Int> {
        return dataSource.counts().map { it * 10 }
    }
}

class FakeDataSource : DataSource {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun counts(): Flow<Int> = flow
}

Quando utilizzi questo falso in un test, puoi creare una coroutine di raccolta che riceverà continuamente i valori da Repository. In questo esempio, li raccogliamo in un elenco e poi eseguiamo asserzioni sui suoi contenuti:

@Test
fun continuouslyCollect() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        repository.scores().toList(values)
    }

    dataSource.emit(1)
    assertEquals(10, values[0]) // Assert on the list contents

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size) // Assert the number of items collected
}

Poiché il flusso esposto da Repository qui non viene mai completato, la chiamata toList che la raccoglie non restituisce mai. L'avvio della raccolta della coroutine in TestScope.backgroundScope garantisce che la coroutine venga annullata prima della fine del test. In caso contrario, runTest continuerà ad attendere il suo completamento, determinando l'interruzione della risposta del test e la fine della mancata riuscita.

Guarda come viene utilizzato UnconfinedTestDispatcher per la raccolta della coroutine qui. Ciò garantisce che la coroutine di raccolta venga avviata con impazienza e sia pronta a ricevere valori dopo la restituzione di launch.

Uso di turbina

La libreria Turbine di terze parti offre una pratica API per la creazione di una coroutina di raccolta, nonché altre funzionalità utili per testare i flussi:

@Test
fun usingTurbine() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    repository.scores().test {
        // Make calls that will trigger value changes only within test{}
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem() // Ignore items if needed, can also use skip(n)

        dataSource.emit(3)
        assertEquals(30, awaitItem())
    }
}

Consulta la documentazione della libreria per ulteriori dettagli.

Test dei flussi di stato

StateFlow è un titolare di dati osservabile, che può essere raccolto per osservare i valori che conserva nel tempo come flusso. Tieni presente che questo flusso di valori è aggregato, il che significa che, se i valori vengono impostati rapidamente in un valore StateFlow, i raccoglitori di questo valore StateFlow non possono ricevere tutti i valori intermedi, ma solo il più recente.

Nei test, se tieni a mente la combinazione, puoi raccogliere i valori di StateFlow allo stesso modo in cui puoi raccogliere qualsiasi altro flusso, anche con Turbine. In alcuni scenari di test può essere auspicabile tentare di raccogliere e asserire su tutti i valori intermedi.

Tuttavia, in genere consigliamo di considerare StateFlow come titolare dei dati e di affermare invece sulla relativa proprietà value. In questo modo, i test convalidano lo stato attuale dell'oggetto in un determinato momento e non dipendono dal fatto che si verifichi o meno la configurazione.

Ad esempio, prendi questo ViewModel che raccoglie i valori da un Repository e li espone all'interfaccia utente in un StateFlow:

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    private val _score = MutableStateFlow(0)
    val score: StateFlow<Int> = _score.asStateFlow()

    fun initialize() {
        viewModelScope.launch {
            myRepository.scores().collect { score ->
                _score.value = score
            }
        }
    }
}

Un'implementazione falsa per questo Repository potrebbe avere il seguente aspetto:

class FakeRepository : MyRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

Quando testi ViewModel con questo falso, puoi emettere valori da questo falso per attivare aggiornamenti nel StateFlow di ViewModel e quindi asserire il valore value aggiornato:

@Test
fun testHotFakeRepository() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)

    assertEquals(0, viewModel.score.value) // Assert on the initial value

    // Start collecting values from the Repository
    viewModel.initialize()

    // Then we can send in values one by one, which the ViewModel will collect
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value) // Assert on the latest value
}

Utilizzo di StateFlows creato da stateIn

Nella sezione precedente, ViewModel utilizza un MutableStateFlow per archiviare il valore più recente emesso da un flusso da Repository. Questo è un modello comune, solitamente implementato in modo più semplice utilizzando l'operatore stateIn, che converte un flusso freddo in un StateFlow caldo:

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

L'operatore stateIn ha un parametro SharingStarted, che determina quando diventa attivo e inizia a utilizzare il flusso sottostante. Opzioni come SharingStarted.Lazily e SharingStarted.WhileSubsribed sono utilizzate spesso in ViewModels.

Anche se stai rivendicando il value del StateFlow nel tuo test, dovrai creare un raccoglitore. Può essere un raccoglitore vuoto:

@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.score.collect()
    }

    assertEquals(0, viewModel.score.value) // Can assert initial value

    // Trigger-assert like before
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)
}

Risorse aggiuntive