Tester les coroutines Kotlin sur Android

Le code de tests unitaires utilisant des coroutines nécessite une attention particulière, car leur exécution peut être asynchrone et se produire sur plusieurs threads. Ce guide vous explique comment tester les fonctions de suspension, quelles constructions de test vous devez maîtriser et comment rendre testable le code utilisant des coroutines.

Les API utilisées dans ce guide font partie de la bibliothèque kotlinx.coroutines.test. Assurez-vous d'abord d'ajouter l'artefact en tant que dépendance de test à votre projet pour avoir accès à ces API.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

Appeler des fonctions de suspension dans les tests

Pour appeler des fonctions de suspension dans les tests, vous devez vous trouver dans une coroutine. Étant donné que les fonctions de test JUnit ne sont pas elles-mêmes des fonctions de suspension, vous devez appeler un constructeur de coroutine à l'intérieur de vos tests pour démarrer une nouvelle coroutine.

runTest est un constructeur de coroutine conçu pour les tests. Utilisez-le pour encapsuler tous les tests qui incluent des coroutines. Notez que les coroutines peuvent être démarrées soit directement dans le corps du test, soit par les objets utilisés dans le test.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

En règle générale, vous devez avoir une invocation de runTest par test. Nous vous recommandons d'utiliser un corps d'expression.

Encapsuler le code de votre test dans runTest fonctionnera pour tester les fonctions de suspension de base et ignorera automatiquement tout retard dans les coroutines, ce qui permettra au test ci-dessus de se terminer en beaucoup moins qu'une seconde.

Cependant, il y a des considérations supplémentaires à prendre en compte, selon ce qui se passe dans votre code en cours de test :

  • Lorsque votre code crée des coroutines autres que la coroutine de test de niveau supérieur créée par runTest, vous devez vérifier la façon dont celles-ci sont programmées en choisissant le TestDispatcher approprié.
  • Si votre code déplace l'exécution de la coroutine vers d'autres coordinateurs (par exemple, à l'aide de withContext), runTest continuera généralement à fonctionner, mais les retards ne seront plus ignorés, et les tests seront moins prévisibles, car le code s'exécute sur plusieurs threads. C'est pourquoi vous devez injecter des coordinateurs de test dans les tests pour remplacer les vrais coordinateurs.

TestDispatchers

Les TestDispatchers sont des implémentations de CoroutineDispatcher à des fins de test. Si des coroutines sont créées pendant le test, vous devrez utiliser des TestDispatchers pour que leur exécution soit prévisible.

Deux implémentations sont disponibles pour TestDispatcher : StandardTestDispatcher et UnconfinedTestDispatcher, qui effectuent des planifications différentes pour les coroutines nouvellement démarrées. Elles utilisent toutes deux un TestCoroutineScheduler pour contrôler le temps virtuel et gérer les coroutines en cours d'exécution dans un test.

Une seule instance de planification doit être utilisée dans un test. Cette instance est partagée entre tous les TestDispatchers. Pour en savoir plus sur le partage des planificateurs, consultez la page Injecter des TestDispatchers.

Pour démarrer la coroutine de test de niveau supérieur, runTest crée un TestScope, une implémentation de CoroutineScope qui utilise toujours un TestDispatcher. Si aucune valeur n'est spécifiée, un TestScope crée un StandardTestDispatcher par défaut et l'utilise pour exécuter la coroutine de test de niveau supérieur.

runTest effectue le suivi des coroutines en file d'attente sur le planificateur utilisé par le coordinateur de son TestScope. Il ne renvoie pas de résultat tant qu'il reste des tâches en attente sur ce planificateur.

StandardTestDispatcher

Lorsque vous lancez de nouvelles coroutines sur un StandardTestDispatcher, elles sont mises en file d'attente sur le planificateur sous-jacent, à exécuter chaque fois que le thread de test peut être utilisé. Pour exécuter ces nouvelles coroutines, vous devez libérer (yield) le thread de test pour que d'autres coroutines puissent l'utiliser. Ce comportement de mise en file d'attente vous permet de contrôler précisément l'exécution des nouvelles coroutines pendant le test. Il ressemble à la planification des coroutines dans le code de production.

Si le thread de test n'est jamais libéré pendant l'exécution de la coroutine de test de niveau supérieur, les nouvelles coroutines ne s'exécutent qu'une fois la coroutine de test terminée (mais avant le retour de runTest) :

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Il existe plusieurs façons de libérer la coroutine de test pour permettre l'exécution des coroutines en file d'attente. Tous ces appels permettent aux autres coroutines de s'exécuter sur le thread de test avant de renvoyer un résultat :

  • advanceUntilIdle : exécute toutes les autres coroutines sur le planificateur jusqu'à ce que la file d'attente soit vide. C'est un bon choix par défaut pour permettre l'exécution de toutes les coroutines en attente. Il fonctionne dans la plupart des scénarios de test.
  • advanceTimeBy : fait avancer le temps virtuel de la quantité donnée et exécute les coroutines planifiées pour qu'elles s'exécutent avant ce point dans le temps virtuel.
  • runCurrent : exécute les coroutines planifiées au temps virtuel actuel.

Pour corriger le test précédent, vous pouvez utiliser advanceUntilIdle pour laisser les deux coroutines en attente effectuer leur travail avant de poursuivre l'assertion :

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

UnconfinedTestDispatcher

Lorsque de nouvelles coroutines sont démarrées sur un UnconfinedTestDispatcher, elles sont exécutées hâtivement sur le thread actuel. Cela signifie qu'elles seront démarrées immédiatement, sans attendre le retour du constructeur de coroutine. Dans de nombreux cas, ce comportement de coordination permet de simplifier le code de test, car il n'est pas nécessaire de libérer manuellement le thread de test pour permettre l'exécution de nouvelles coroutines.

Cependant, ce comportement est différent de ce que vous verrez en production avec des coordinateurs non-test. Si votre test est axé sur la simultanéité, utilisez de préférence StandardTestDispatcher.

Pour utiliser ce coordinateur pour la coroutine de test de niveau supérieur dans runTest au lieu de celle par défaut, créez une instance et transmettez-la en tant que paramètre. Les coroutines créées dans runTest seront exécutées hâtivement, car elles héritent du coordinateur de TestScope.

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

Dans cet exemple, les appels launch démarreront hâtivement leurs nouvelles coroutines pour UnconfinedTestDispatcher, ce qui signifie que chaque appel launch ne reviendra qu'une fois l'enregistrement terminé.

N'oubliez pas que si UnconfinedTestDispatcher lance hâtivement de nouvelles coroutines, cela ne veut pas dire qu'il les exécutera hâtivement jusqu'à la fin. Si la nouvelle coroutine est suspendue, les autres coroutines reprennent.

Par exemple, la nouvelle coroutine lancée lors de ce test enregistrera Alice, puis elle se suspend lorsque la méthode delay est appelée. Cela permet à la coroutine de niveau supérieur de poursuivre l'assertion, et le test échoue, car Bob n'est pas encore enregistré :

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Injecter des coordinateurs de test

Le code en cours de test peut utiliser des coordinateurs pour changer de thread (avec withContext) ou pour démarrer de nouvelles coroutines. Lorsque le code est exécuté par plusieurs threads en parallèle, les tests peuvent devenir imprévisibles. Il peut être difficile d'effectuer des assertions au bon moment ou d'attendre la fin d'une tâche si elle s'exécute dans des threads d'arrière-plan sur lesquels vous n'avez aucun contrôle.

Lors des tests, remplacez ces coordinateurs par des instances de TestDispatchers. Cela présente plusieurs avantages :

  • Le code sera exécuté dans un seul thread de test, ce qui rendra les tests plus déterministes.
  • Vous pouvez contrôler la façon dont les nouvelles coroutines sont planifiées et exécutées.
  • Les TestDispatchers utilisent un planificateur pour le temps virtuel, ce qui ignore automatiquement les retards et vous permet d'avancer le temps manuellement.

En utilisant l'injection de dépendances pour fournir des coordinateurs à vos classes, vous pouvez facilement remplacer les vrais coordinateurs lors des tests. Dans ces exemples, nous allons injecter un CoroutineDispatcher, mais vous pouvez également injecter le type CoroutineContext plus large, ce qui offre encore plus de flexibilité pendant les tests.

Pour les classes qui démarrent des coroutines, vous pouvez également injecter un CoroutineScope au lieu d'un coordinateur, comme indiqué dans la section Injecter un champ d'application.

Par défaut, lorsqu'ils sont instanciés, les TestDispatchers créent un nouveau planificateur. Dans runTest, vous pouvez accéder à la propriété testScheduler du TestScope et la transmettre aux TestDispatchers nouvellement créés. Cela leur permettra de partager leur compréhension du temps virtuel, et des méthodes comme advanceUntilIdle exécuteront des coroutines sur tous les coordinateurs de test jusqu'à la fin.

Dans l'exemple suivant, vous pouvez voir une classe Repository qui crée une coroutine à l'aide du coordinateur IO dans sa méthode initialize et qui fait passer l'appelant vers le coordinateur IO dans sa méthode fetchData :

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

Lors des tests, vous pouvez injecter une implémentation TestDispatcher pour remplacer le coordinateur IO.

Dans l'exemple ci-dessous, nous injectons un StandardTestDispatcher dans le dépôt et utilisons advanceUntilIdle pour nous assurer que la nouvelle coroutine démarrée dans initialize se termine avant de continuer.

fetchData bénéficiera également de l'exécution sur un TestDispatcher, car il s'exécutera sur le thread de test et ignorera le délai indiqué pendant le test.

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

Les nouvelles coroutines démarrées sur un TestDispatcher peuvent être avancées manuellement, comme illustré ci-dessus avec initialize. Notez, cependant, que cela n'est ni possible, ni souhaitable dans un code de production. La méthode devrait plutôt être modifiée soit pour suspendre l'opération (pour une exécution séquentielle), soit pour retourner une valeur Deferred (pour une exécution simultanée).

Par exemple, vous pouvez utiliser async pour démarrer une nouvelle coroutine et créer un Deferred :

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

Cela vous permet d'await de manière sécurisée la fin de l'exécution du code dans les tests et dans le code de production :

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest attendra que les coroutines en attente se terminent avant d'indiquer si les coroutines sont sur un TestDispatcher avec lequel il partage un planificateur. Il attendra également les coroutines enfants de la coroutine de test de niveau supérieur, même si elles se trouvent sur d'autres coordinateurs (jusqu'à la fin du délai spécifié par le paramètre dispatchTimeoutMs, soit 60 secondes par défaut).

Configurer le coordinateur principal

Dans les tests unitaires locaux, le coordinateur Main qui encapsule le thread UI Android n'est pas disponible, car ces tests sont exécutés sur une JVM locale et non sur un appareil Android. Si votre code en cours de test fait référence au thread principal, une exception sera générée lors des tests unitaires.

Dans certains cas, vous pouvez injecter le coordinateur Main de la même manière que les autres, comme décrit dans la section précédente. Vous pouvez ainsi le remplacer par un TestDispatcher lors des tests. Cependant, certaines API telles que viewModelScope utilisent en arrière-plan un coordinateur Main codé en dur.

Voici un exemple d'implémentation de ViewModel qui utilise viewModelScope pour lancer une coroutine qui charge des données :

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

Dans tous les cas, pour remplacer le coordinateur Main par un TestDispatcher, utilisez les fonctions Dispatchers.setMain et Dispatchers.resetMain.

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

Si le coordinateur Main a été remplacé par un TestDispatcher, les nouveaux TestDispatchers utiliseront automatiquement le planificateur du coordinateur Main, y compris le StandardTestDispatcher créé par runTest si aucun autre coordinateur ne lui est transmis.

Il est ainsi plus facile de s'assurer qu'un seul planificateur est utilisé pendant le test. Pour que cela fonctionne, veillez à créer toutes les autres instances TestDispatcher après avoir appelé Dispatchers.setMain.

Pour éviter de dupliquer le code qui remplace le coordinateur Main dans chaque test, vous pouvez l'extraire dans une règle de test JUnit :

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

Cette implémentation de règle utilise un UnconfinedTestDispatcher par défaut, mais un StandardTestDispatcher peut être transmis en tant que paramètre si le coordinateur Main ne doit pas s'exécuter hâtivement dans une classe de test donnée.

Lorsque vous avez besoin d'une instance TestDispatcher dans le texte test, vous pouvez réutiliser le testDispatcher de la règle, à condition qu'il s'agisse du type souhaité. Si vous souhaitez indiquer explicitement le type de TestDispatcher utilisé lors du test, ou si vous avez besoin d'un TestDispatcher différent de celui utilisé pour Main, vous pouvez créer un TestDispatcher dans un runTest. Comme le coordinateur Main est défini sur TestDispatcher, les TestDispatchers nouvellement créés partagent automatiquement son planificateur.

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

Créer des coordinateurs en dehors d'un test

Dans certains cas, vous devrez peut-être disposer d'un TestDispatcher en dehors de la méthode de test. Lors de l'initialisation d'une propriété dans la classe de test, par exemple :

class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

Si vous remplacez le coordinateur Main comme indiqué dans la section précédente, les TestDispatchers créés après le remplacement du coordinateur Main partagent automatiquement son planificateur.

Ce n'est toutefois pas le cas pour les TestDispatchers créés en tant que propriétés de la classe de test ou les TestDispatchers créés lors de l'initialisation des propriétés dans la classe de test. Dans ce cas, ceux-ci sont initialisés avant le remplacement du coordinateur Main. Par conséquent, de nouveaux planificateurs sont créés.

Pour vous assurer que vous n'avez qu'un seul planificateur dans votre test, créez d'abord la propriété MainDispatcherRule. Réutilisez ensuite, si nécessaire, son coordinateur (ou son planificateur, si vous avez besoin d'un TestDispatcher d'un type différent) dans les initialiseurs d'autres propriétés de classe.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = Repository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

Notez que les runTest et les TestDispatchers créés dans le test continuent à partager automatiquement le planificateur du coordinateur Main.

Si vous ne remplacez pas le coordinateur Main, créez votre premier TestDispatcher (ce qui crée un planificateur) en tant que propriété de la classe. Ensuite, transmettez manuellement ce planificateur à chaque appel de runTest et à chaque TestDispatcher créé, à la fois en tant que propriétés et dans le test :

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

Dans cet exemple, le planificateur du premier coordinateur est transmis à runTest. Cette opération entraîne la création d'un StandardTestDispatcher pour le TestScope utilisant ce planificateur. Vous pouvez également transmettre directement le coordinateur à runTest pour exécuter la coroutine de test sur ce coordinateur.

Créer votre propre TestScope

Comme avec les TestDispatchers, vous devrez peut-être accéder à un TestScope en dehors du texte test. Bien que runTest crée automatiquement un TestScope en arrière-plan, vous pouvez créer votre propre TestScope à utiliser avec runTest.

Si vous procédez ainsi, veillez à appeler runTest sur le TestScope que vous avez créé :

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Le code ci-dessus crée implicitement un StandardTestDispatcher pour le TestScope, ainsi qu'un nouveau planificateur. Vous pouvez également créer ces objets explicitement. Cela peut être utile si vous devez l'intégrer à des configurations d'injection de dépendances.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Injecter un champ d'application

Si vous avez une classe qui crée des coroutines que vous devez contrôler pendant les tests, vous pouvez injecter un champ d'application de coroutine dans cette classe en la remplaçant par un TestScope dans les tests.

Dans l'exemple suivant, la classe UserState dépend d'un UserRepository pour enregistrer de nouveaux utilisateurs et extraire la liste des utilisateurs enregistrés. Comme ces appels à UserRepository suspendent les appels de fonction, UserState utilise le CoroutineScope injecté pour démarrer une nouvelle coroutine dans sa fonction registerUser.

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

Pour tester cette classe, vous pouvez transmettre le TestScope à partir du runTest lors de la création de l'objet UserState :

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

Pour injecter un champ d'application en dehors de la fonction de test (par exemple, dans un objet testé qui est créé en tant que propriété de la classe de test), consultez la section Créer votre propre TestScope.

Ressources supplémentaires