Tester des flux Kotlin sur Android

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
Figure 1. L'objet du test et la couche de données.

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
Figure 2. Les dépendances sont remplacées par une implémentation fictive.

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
Figure 3. Un dépôt (l'objet du test) avec des dépendances fictives qui expose un flux.

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é. Le 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

La bibliothèque tierce Turbine offre une API pratique pour créer une coroutine de collecte, ainsi que d'autres fonctionnalités pratiques pour tester les flux:

@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é de manière plus simple à l'aide de l'opérateur 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