Il modo in cui testi le unità o i moduli che comunicano con flow dipende dal fatto che il soggetto in esame utilizzi il flusso come input o output.
- Se il soggetto in esame osserva un flusso, puoi generare flussi all'interno di dipendenze fittizie 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 falso
Quando il soggetto in fase di test è un consumatore di un flusso, un modo comune per testarlo è sostituire il produttore con un'implementazione fittizia. Ad esempio, data una classe che osserva un repository che acquisisce dati da due origini dati in produzione:
Per rendere il test deterministico, puoi sostituire il repository e le relative dipendenze con un repository fittizio che emette sempre gli stessi dati fittizi:
Per emettere una serie predefinita di valori in un flusso, utilizza il generatore flow:
class MyFakeRepository : MyRepository { fun observeCount() = flow { emit(ITEM_1) } }
Nel test, questo repository fittizio viene inserito, sostituendo l'implementazione reale:
@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 in fase di test, puoi verificare che funzioni correttamente controllando i suoi output.
Asserzione delle emissioni di flusso in un test
Se il soggetto in esame espone un flusso, il test deve effettuare asserzioni sugli elementi dello stream di dati.
Supponiamo che il repository dell'esempio precedente esponga un flusso:
Con alcuni test, dovrai controllare solo la prima emissione o un numero finito di elementi provenienti dal flusso.
Puoi utilizzare la prima emissione nel flusso chiamando first(). Questa
funzione attende la ricezione del primo elemento e poi invia il segnale di annullamento
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 di toList() fa sì che il flusso
attenda che l'origine emetta tutti i suoi valori e poi li restituisca come
elenco. Funziona solo per gli stream di dati finiti.
@Test fun myRepositoryTestList() = runTest { val repository = MyFakeRepository() // 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 i flussi 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 selezionare 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 utilizzando toList() come mostrato nell'esempio precedente utilizza
collect() internamente e sospende l'operazione finché l'intero elenco dei risultati non è pronto per essere
restituito.
Per alternare le azioni che fanno sì che il flusso emetta valori e asserzioni sui valori emessi, puoi raccogliere continuamente i valori da un flusso durante un test.
Ad esempio, prendi la seguente classe Repository da testare e un'implementazione di un'origine dati fittizia di accompagnamento che ha 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 test fittizio in un test, puoi creare una coroutine di raccolta che riceverà
continuamente i valori da Repository. In questo esempio, li raccogliamo in un elenco ed eseguiamo asserzioni sui relativi contenuti:
@OptIn(ExperimentalCoroutinesApi::class) @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 lo raccoglie non viene mai restituita. L'avvio della coroutine di raccolta in
TestScope.backgroundScope
garantisce che la coroutine venga annullata prima della fine del test. In caso contrario,
runTest continuerebbe ad attendere il completamento, causando l'interruzione della risposta del test e il suo eventuale esito negativo.
Nota come
UnconfinedTestDispatcher
viene utilizzato per la coroutine di raccolta qui. In questo modo, la coroutine di raccolta viene avviata immediatamente ed è pronta a ricevere valori dopo la restituzione di launch.
Utilizzo di Turbine
La libreria Turbine di terze parti offre un'API pratica per creare una coroutine di raccolta, nonché altre funzionalità pratiche 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()) } }
Per ulteriori dettagli, consulta la documentazione della libreria.
Test di StateFlow
StateFlow è un contenitore di dati osservabile, che può essere raccolto per osservare i valori che contiene nel tempo come flusso. Tieni presente che questo flusso di valori è combinato, il che significa che se i valori vengono impostati rapidamente in un StateFlow, i raccoglitori di questo StateFlow non hanno la garanzia di ricevere tutti i valori intermedi, ma solo quello più recente.
Nei test, se tieni presente la conflazione, puoi raccogliere i valori di un StateFlow
come puoi raccogliere qualsiasi altro flusso, anche con Turbine. Tentare di raccogliere
e asserire tutti i valori intermedi può essere auspicabile in alcuni scenari di test.
Tuttavia, in genere consigliamo di considerare StateFlow come titolare dei dati e di eseguire l'asserzione 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 la conflazione avvenga o meno.
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 fittizia 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 fake, puoi emettere valori dal fake
per attivare gli aggiornamenti in StateFlow di ViewModel, quindi asserire su
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 StateFlow creati da stateIn
Nella sezione precedente, ViewModel utilizza MutableStateFlow per memorizzare
l'ultimo valore emesso da un flusso da Repository. Si tratta di un pattern comune,
solitamente implementato in modo più semplice utilizzando l'operatore
stateIn, che converte un flusso freddo in un flusso caldo StateFlow:
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.WhileSubscribed vengono utilizzate di frequente
nei modelli di visualizzazione.
Anche se esegui l'asserzione su value di StateFlow nel test, devi
creare un raccoglitore. Può trattarsi di un raccoglitore vuoto:
@OptIn(ExperimentalCoroutinesApi::class) @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
- Test delle coroutine Kotlin su Android
- Flussi Kotlin su Android
StateFloweSharedFlow- Risorse aggiuntive per le coroutine e i flussi Kotlin