Test dei flussi Kotlin su Android

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

  • Se il soggetto sottoposto a test osserva un flusso, puoi generare flussi all'interno con 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.
di Gemini Advanced.

Creare un finto produttore

Quando il soggetto sottoposto a test è un consumatore di un flusso, un modo comune per testarlo è la sostituzione del producer con una falsa implementazione. Ad esempio, in base a un 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 al test e i dati livello di sicurezza.

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

vengono sostituite con una falsa implementazione
Figura 2. Le dipendenze vengono sostituite da un valore falso implementazione.

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

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

Nel test, viene inserito questo falso repository, sostituendo il repository 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 del soggetto sottoposto a test, puoi e verificare che funzioni correttamente controllando gli output.

Dichiarazione delle emissioni di flusso in un test

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

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

con dipendenze false che espone un flusso
Figura 3. Un repository (il soggetto sottoposto al test) contenente che espone un flusso.

Con alcuni test, dovrai verificare solo la prima emissione o un intervallo di elementi provenienti dal flusso.

Puoi consumare la prima emissione nel flusso chiamando first(). Questo attende che venga ricevuto il primo elemento, quindi invia l'annullamento segnale al produttore.

@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 controllare più valori, la chiamata a toList() causa il flusso attendere che l'origine emetta tutti i valori, quindi li restituisce come dall'elenco di lettura. Questa operazione funziona solo per flussi di dati finiti.

@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 articoli o non restituiscono un numero limitato di elementi, puoi usare l'API Flow per scegliere e trasformare 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 utilizzando toList(), come mostrato nell'esempio precedente, utilizza collect() internamente e sospenderlo finché l'intero elenco dei risultati non sarà pronto restituito.

Per alternare le azioni che causano l'emissione di valori e asserzioni nel flusso valori emessi, è possibile raccogliere continuamente valori da un flusso durante un test.

Ad esempio, completa il seguente corso Repository da testare e un che accompagna l'implementazione di origini dati false che hanno 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 coroutina da collezionare che ricevono continuamente i valori da Repository. In questo esempio, raccoglierle in un elenco e quindi eseguire asserzioni sui relativi 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, toList che raccoglie e non restituisce mai. Inizio della raccolta della coroutine TestScope.backgroundScope assicura che la coroutine venga annullata prima della fine del test. Altrimenti, runTest avrebbe continuato ad attendere il completamento, causando l'interruzione del test non risponde e alla fine non riesce.

Nota come UnconfinedTestDispatcher utilizzata per la raccolta della coroutine. Ciò garantisce che la raccolta la coroutine è stata lanciata con entusiasmo ed è pronta a ricevere valori dopo il giorno launch i resi.

Uso della turbina

Turbine di terze parti libreria offre una pratica API per la creazione di una coroutina, 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 le documentazione della libreria per ulteriori dettagli.

Test di StateFlows

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

Nei test, se tieni presente la combinazione, puoi raccogliere i valori di un StateFlow perché puoi raccogliere qualsiasi altro flusso, anche con Turbine. Tentativo di raccolta e affermare su tutti i valori intermedi può essere desiderabile in alcuni scenari di test.

Tuttavia, in genere consigliamo di considerare StateFlow come titolare dei dati e asserzione nella sua proprietà value. In questo modo, i test convalidano dell'oggetto in un determinato momento e non dipendono dal fatto che avviene una combinazione.

Ad esempio, prendi questo ViewModel che raccoglie i valori da un Repository e e li espone alla UI 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 il ViewModel con questo falso, puoi emettere valori dal falso a attiva gli aggiornamenti nell'elemento StateFlow di ViewModel e poi applica l'oggetto value:

@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 StateFlow creati da stateIn

Nella sezione precedente, il ViewModel utilizza un MutableStateFlow per archiviare più recente valore emesso da un flusso proveniente da Repository. Questo è un pattern comune, di solito implementato in modo più semplice utilizzando stateIn che converte un flusso freddo in un StateFlow a 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 utilizzati di frequente in ViewModels.

Anche se le tue dichiarazioni sono sulla base di value di StateFlow nel tuo test, devi 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