La façon dont vous testez les unités ou les modules qui communiquent avec Flow dépend de si l'objet du test utilise le flux en entrée ou en sortie.
- Si l'objet du test utilise un flux, vous pouvez générer des flux au sein de fausses dépendances que vous pouvez contrôler à partir de tests.
- Si l'unité ou le module expose un flux, vous pouvez lire et vérifier un ou plusieurs éléments émis par un flux dans le test.
Créer un producteur fictif
Lorsque l'objet du test est un consommateur d'un flux, une méthode courante de le tester consiste à remplacer le producteur par une implémentation fictive. Prenons l'exemple d'une classe qui surveille un dépôt qui reçoit des données de deux sources de données en production :
![L'objet du test et la couche de données](https://developer.android.com/static/images/kotlin/flow/sut-data-layer.png?authuser=9&hl=fr)
Pour que le test soit déterministe, vous pouvez remplacer le dépôt et ses dépendances par un dépôt fictif qui émet toujours les mêmes données fictives :
![Les dépendances sont remplacées par une implémentation fictive](https://developer.android.com/static/images/kotlin/flow/fake-implementation.png?authuser=9&hl=fr)
Pour émettre une série prédéfinie de valeurs dans un flux, utilisez le compilateur de flow
:
class MyFakeRepository : MyRepository {
fun observeCount() = flow {
emit(ITEM_1)
}
}
Dans le test, ce dépôt fictif est injecté, remplaçant la vraie implémentation :
@Test
fun myTest() {
// Given a class with fake dependencies:
val sut = MyUnitUnderTest(MyFakeRepository())
// Trigger and verify
...
}
Maintenant que vous avez le contrôle sur les sorties de l'objet du test, vous pouvez vérifier qu'il fonctionne correctement en vérifiant ses sorties.
Effectuer des assertions sur les émissions de flux lors d'un test
Si l'objet du test expose un flux, le test doit effectuer des assertions sur les éléments du flux de données.
Supposons que le dépôt de l'exemple précédent expose un flux :
![Un dépôt avec des dépendances fictives qui expose un flux](https://developer.android.com/static/images/kotlin/flow/test-exposed-flow.png?authuser=9&hl=fr)
Avec certains tests, vous ne devez vérifier que la première émission ou un nombre limité d'éléments provenant du flux.
Vous pouvez consommer la première émission dans le flux en appelant first()
. Cette fonction attend que le premier élément soit reçu, puis envoie le signal d'annulation au producteur.
@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)
}
Si le test doit vérifier plusieurs valeurs, l'appel à toList()
force le flux à attendre que la source émette toutes ses valeurs, puis les renvoie sous forme de liste. Cela ne fonctionne que pour les flux de données limités.
@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)
}
Pour les flux de données qui nécessitent une collecte d'éléments plus complexe ou qui ne renvoient pas un nombre limité d'éléments, vous pouvez utiliser l'API Flow
pour sélectionner et transformer les éléments. Voici quelques exemples :
// 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)
Collecte continue pendant un test
La collecte d'un flux à l'aide de toList()
, comme dans l'exemple précédent, utilise collect()
en interne et se suspend jusqu'à ce que la liste complète des résultats soit prête à être renvoyée.
Pour entrelacer les actions qui entraînent l'émission de valeurs et d'assertions par le flux sur les valeurs émises, vous pouvez collecter en continu les valeurs d'un flux pendant un test.
Prenons, par exemple, la classe Repository
suivante à tester et une implémentation de source de données fictive qui l'accompagne et qui possède une méthode emit
pour produire des valeurs de manière dynamique pendant le 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
}
Lorsque vous utilisez cette implémentation fictive dans un test, vous pouvez créer une coroutine de collecte qui recevra en continu les valeurs de Repository
. Dans cet exemple, nous les collectons dans une liste, puis nous effectuons des assertions sur son contenu :
@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
}
Comme le flux exposé par Repository
ne se termine jamais, l'appel toList
qui le collecte n'est jamais renvoyé. Démarrage de la coroutine de collecte dans
TestScope.backgroundScope
garantit que la coroutine est annulée avant la fin du test. Sinon, runTest
continue d'attendre la fin de son exécution, ce qui empêche le test de répondre et, à terme, entraîne son échec.
Notez comment UnconfinedTestDispatcher
est utilisé ici pour la coroutine de collecte. Cela garantit qu'elle est lancée hâtivement et qu'elle est prête à recevoir des valeurs une fois que launch
est renvoyé.
Utiliser Turbine
L'outil Turbine tiers offre une API pratique pour créer une coroutine de collecte. ainsi que d'autres fonctionnalités pratiques pour tester les Flow:
@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())
}
}
Pour plus d'informations, consultez la documentation de la bibliothèque.
Tester des StateFlows
StateFlow
est un conteneur de données observable qui peut être collecté pour surveiller les valeurs qu'il contient au fil du temps en tant que flux. Notez que ce flux de valeurs est combiné. Par conséquent, si les valeurs sont définies rapidement dans un StateFlow
, les collecteurs de ce StateFlow
ne reçoivent pas forcément toutes les valeurs intermédiaires, uniquement la plus récente.
Dans les tests, si vous partez de ce principe de combinaison des flux, vous pouvez collecter les valeurs d'un StateFlow
comme n'importe quel autre flux, y compris avec Turbine. Dans certains scénarios de test, il peut être souhaitable d'essayer de collecter toutes les valeurs intermédiaires et d'effectuer des assertions dessus.
Cependant, nous recommandons généralement de traiter StateFlow
comme un conteneur de données et d'effectuer plutôt des assertions sur sa propriété value
. De cette façon, les tests valident l'état actuel de l'objet à un moment donné et ne dépendent pas de la présence ou non d'une combinaison de flux.
Prenons par exemple ce ViewModel qui collecte des valeurs à partir d'un Repository
et les expose à l'interface utilisateur dans 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
}
}
}
}
Une implémentation fictive pour ce Repository
peut se présenter comme suit :
class FakeRepository : MyRepository {
private val flow = MutableSharedFlow<Int>()
suspend fun emit(value: Int) = flow.emit(value)
override fun scores(): Flow<Int> = flow
}
Lorsque vous testez le ViewModel avec cette implémentation fictive, vous pouvez émettre des valeurs à partir de celle-ci pour déclencher des mises à jour dans le StateFlow
du ViewModel, et effectuer ensuite des assertions sur l'élément value
mis à jour :
@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
}
Utiliser des StateFlows créés par stateIn
Dans la section précédente, le ViewModel utilise un MutableStateFlow
pour stocker la dernière valeur émise par un flux provenant du Repository
. Il s’agit d’un
modèle courant,
généralement implémentées de manière plus simple à l'aide du
stateIn
qui convertit un flux froid en StateFlow
chaud:
class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
val score: StateFlow<Int> = myRepository.scores()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}
L'opérateur stateIn
possède un paramètre SharingStarted
qui détermine quand il devient actif et commence à utiliser le flux sous-jacent. Des options telles que SharingStarted.Lazily
et SharingStarted.WhileSubsribed
sont fréquemment utilisées dans les ViewModels.
Même si vous effectuez une assertion sur la value
du StateFlow
dans votre test, vous devez créer un collecteur. Il peut s'agir d'un collecteur vide :
@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)
}
Ressources supplémentaires
- Tester des coroutines Kotlin sur Android
- Flux Kotlin sur Android
StateFlow
etSharedFlow
- Ressources supplémentaires sur les coroutines Kotlin et Flow