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 leTestDispatcher
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 aussi
injectez les plus
CoroutineContext
ce qui offre encore plus de flexibilité lors des 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 ExampleRepository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ } class RepositoryTestWithRule { private val repository = ExampleRepository(/* 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 = ExampleRepository(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 = ExampleRepository(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.