Il modo in cui testi le unità o i moduli che comunicano con il flusso dipende dal fatto che l'oggetto sottoposto a test utilizzi il flusso come input o output.
- Se l'oggetto 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 falso
Quando l'oggetto sottoposto a test è un consumatore di un flusso, un modo comune per testarlo è sostituire il produttore con un'implementazione falsa. 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 falso che emette sempre gli stessi dati falsi:
Per emettere una serie di valori predefiniti in un flusso, utilizza il generatore flow
:
class MyFakeRepository : MyRepository {
fun observeCount() = flow {
emit(ITEM_1)
}
}
Nel test, questo repository falso 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 dell'oggetto sottoposto a test, puoi verificare che funzioni correttamente controllandoli.
Verificare le emissioni del flusso in un test
Se l'oggetto sottoposto a test espone un flusso, il test deve fare affermazioni sugli elementi dello stream di dati.
Supponiamo che il repository dell'esempio precedente esponga un flusso:
Con alcuni test, dovrai solo controllare la prima emissione o un numero limitato di elementi provenienti dal flusso.
Puoi utilizzare la prima emissione nel flusso chiamando first()
. Questa funzione attende di ricevere il primo elemento e poi invia l'indicatore 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 a toList()
fa in modo che il flusso aspetti che l'origine emetta tutti i suoi valori e poi li restituisce come elenco. Questo funziona solo per stream 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 di elementi più complessa 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 utilizzando toList()
come nell'esempio precedente utilizza collect()
internamente e si sospende finché l'intero elenco dei risultati non è pronto per essere restituito.
Per intercalare azioni che inducono il flusso a emettere valori e verifiche 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 dell'origine dati falsa associata che ha un metodo emit
per produrre valori dinamicamente 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 raccoglieremo in un elenco ed eseguiremo affermazioni 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, la chiamata toList
che lo raccoglie non viene mai restituita. L'avvio della coroutine di raccolta in
TestScope.backgroundScope
assicura che la coroutine venga annullata prima del termine del test. In caso contrario,
runTest
continuerebbe ad attendere il completamento, causando l'interruzione della risposta del test e, infine, il suo fallimento.
Nota come viene utilizzato
UnconfinedTestDispatcher
per la coroutine di raccolta qui. In questo modo, la coroutine di raccolta viene avviata in modo esplicito ed è pronta a ricevere valori dopo il ritorno di launch
.
Utilizzare Turbine
La libreria di terze parti Turbine 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 stream. Tieni presente che questo stream di valori viene unito, il che significa che se i valori vengono impostati rapidamente in un StateFlow
, i collezionisti di questo StateFlow
non hanno la certezza 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 qualsiasi altro flusso, anche con Turbine. In alcuni scenari di test, può essere opportuno tentare di raccogliere
e verificare tutti i valori intermedi.
Tuttavia, in genere consigliamo di trattare StateFlow
come un contenitore di dati e di eseguire l'affermazione sulla relativa proprietà value
. In questo modo, i test convalidano lo stato corrente dell'oggetto in un determinato momento e non dipendono dal verificarsi o meno della conflazione.
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 dal falso
per attivare gli aggiornamenti nel StateFlow
di ViewModel
, quindi eseguire assert sul
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 un MutableStateFlow
per memorizzare il valore più recente emesso da un flusso dal Repository
. Si tratta di un pattern 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.WhileSubscribed
vengono utilizzate spesso
nei modelli di visualizzazione.
Anche se nel test esegui l'affermazione sull'value
dell'StateFlow
, 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
- Test delle coroutine Kotlin su Android
- Flusso di Kotlin su Android
StateFlow
eSharedFlow
- Risorse aggiuntive per le coroutine e il flusso di Kotlin