Un projet Compose qui grossit sans tests automatiques se met vite à régresser. Une modification dans un composable casse silencieusement un autre écran, l’équipe perd confiance, et chaque release devient une session de tests manuels stressants. La couche test officielle pour Jetpack Compose s’appelle androidx.compose.ui.test et expose une API expressive pour piloter l’arbre de composables dans un test instrumenté ou Robolectric. Combinée à du test unitaire JVM côté ViewModel et à du test de DAO côté Room, elle forme une pyramide complète. Ce tutoriel reprend la mise en place pas à pas et couvre les patterns réutilisables.
Prérequis
- Projet Compose avec au moins un écran fonctionnel (cf. Premiers écrans Compose)
- ViewModel et StateFlow déjà en place (cf. ViewModel et StateFlow)
- Émulateur Android 16 (API 36) configuré, ou Robolectric installé
- Temps estimé : 90 minutes
Étape 1 — Comprendre les trois étages de tests
Une application bien testée a trois niveaux. Les tests unitaires JVM (rapides, sans Android) couvrent les ViewModels, les Repositories, les classes métier — environ 70 % de la couverture. Les tests d’intégration (Robolectric ou émulateur) couvrent Room, les DataStore, la sérialisation — environ 20 %. Les tests UI Compose (émulateur ou Robolectric) couvrent les parcours utilisateur critiques — environ 10 %. Cette pyramide tient en équilibre entre temps d’exécution et confiance acquise.
Pour Jetpack Compose, on s’intéresse à la pointe de la pyramide : tester qu’un écran affiche le bon contenu selon son UiState, que les clics déclenchent les bons appels au ViewModel, et que les transitions entre états (chargement, erreur, succès) se passent correctement. Ces tests sont plus coûteux à exécuter qu’un test JVM, mais ils valident des contrats UI qu’aucun autre type de test ne peut couvrir.
Étape 2 — Ajouter les dépendances de test
Les dépendances de test Compose sont déclarées avec androidTestImplementation pour les tests instrumentés (qui tournent sur émulateur), ou testImplementation pour les tests Robolectric (JVM avec émulation Android). Pour démarrer, on cible d’abord les tests instrumentés qui sont le mode officiel recommandé.
[versions]
junit = "4.13.2"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
composeBom = "2026.05.00"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
Dans app/build.gradle.kts, déclarez les dépendances. Notez le debugImplementation pour ui-test-manifest : il ajoute uniquement au build debug, ce qu’on veut pour le test sans alourdir la release.
dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Étape 3 — Écrire son premier test Compose
Un test Compose utilise un ComposeTestRule qui pilote un environnement isolé. Le test déclare un arbre de composables avec setContent, puis interagit avec via des sélecteurs (par texte, par contentDescription, par tag), et vérifie les états attendus. Voici un test minimal qui valide le rendu d’une carte d’article.
@RunWith(AndroidJUnit4::class)
class WelcomeCardTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun afficherTitreEtDescription() {
composeTestRule.setContent {
HelloKotlinTheme {
WelcomeCard(
title = "Mon titre",
description = "Ma description"
)
}
}
composeTestRule.onNodeWithText("Mon titre").assertIsDisplayed()
composeTestRule.onNodeWithText("Ma description").assertIsDisplayed()
}
}
Quatre éléments à retenir. createComposeRule() crée un environnement Compose isolé sans Activity Android complète — rapide à démarrer. setContent { ... } est l’équivalent du setContent de l’Activity réelle. onNodeWithText("...") sélectionne le premier nœud contenant ce texte. assertIsDisplayed() vérifie qu’il est rendu et visible. Si le sélecteur ne trouve rien, le test échoue avec un message clair.
Étape 4 — Tester les interactions utilisateur
Au-delà de l’affichage, on veut vérifier que les clics, saisies et gestures déclenchent les bons callbacks. L’API expose des actions correspondantes : performClick(), performTextInput(), performScrollTo(). On vérifie l’effet via une lambda spy passée au composable.
@Test
fun clickerArticleAppelleCallback() {
var idClique = -1
composeTestRule.setContent {
HelloKotlinTheme {
ListeArticles(
articles = listOf(
Article(id = 42, titre = "Titre 42", contenu = "...")
),
onArticleClick = { idClique = it.id }
)
}
}
composeTestRule.onNodeWithText("Titre 42").performClick()
assertEquals(42, idClique)
}
Cette technique de spy par variable locale fonctionne pour les tests simples. Pour des cas plus complexes, on utilise un faux ViewModel ou un faux callback object qui enregistre les appels. L’avantage de la lambda directe : elle reste lisible et ne dépend d’aucune bibliothèque mock. On peut aussi vérifier qu’un callback n’a pas été appelé en initialisant idClique à une valeur sentinelle.
Étape 5 — Tester un écran avec ViewModel
Pour tester un écran complet, on injecte un faux ViewModel qui contrôle le UiState directement. Le test instancie un MutableStateFlow qui simule les retours du repository, et on bascule ses valeurs pour vérifier que l’UI réagit correctement.
@Test
fun afficheSpinnerPendantChargement() {
val fakeRepo = FakeArticlesRepository(delaiInfini = true)
val viewModel = ListeArticlesViewModel(fakeRepo)
composeTestRule.setContent {
HelloKotlinTheme {
EcranListeArticles(viewModel = viewModel)
}
}
composeTestRule.onNodeWithTag("loading").assertIsDisplayed()
}
@Test
fun afficheErreurAvecBoutonReessayer() = runTest {
val fakeRepo = FakeArticlesRepository(erreur = "Connexion impossible")
val viewModel = ListeArticlesViewModel(fakeRepo)
composeTestRule.setContent {
HelloKotlinTheme {
EcranListeArticles(viewModel = viewModel)
}
}
composeTestRule.onNodeWithText("Connexion impossible", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("Réessayer").performClick()
}
On utilise onNodeWithTag pour sélectionner par Modifier.testTag(« loading ») — pratique pour des composables sans texte fixe comme un CircularProgressIndicator. Les testTag doivent rester rares (sinon ils polluent le code production), réservés aux nœuds qu’on a besoin de cibler en test.
Étape 6 — Tester un parcours multi-écrans avec navigation
Pour tester un parcours qui traverse plusieurs écrans, on utilise createAndroidComposeRule<MainActivity>() qui démarre la vraie Activity avec son NavHost. Le test navigue, clique, et vérifie l’écran cible — exactement comme un utilisateur réel.
@RunWith(AndroidJUnit4::class)
class ParcoursNavigationTest {
@get:Rule val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun ouvrirArticleAffichePageDetail() {
composeTestRule.onNodeWithText("Premier article").performClick()
composeTestRule.onNodeWithText("Contenu du premier article").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Retour").assertIsDisplayed()
}
}
Pour ces tests d’intégration, on configure une MainActivity de test qui injecte un faux Repository via Hilt (@HiltAndroidTest + HiltAndroidRule). Cela évite la dépendance à un serveur réel et garantit des données déterministes. Le coût : la mise en place initiale prend une heure, mais une fois en place, chaque nouveau parcours se teste en dix lignes.
Étape 7 — Exécuter et intégrer en CI
Les tests instrumentés se lancent via ./gradlew connectedDebugAndroidTest ou directement depuis Android Studio (clic droit sur la classe de test → Run). Sur un Mac M2, l’émulateur démarre en 20-30 secondes, l’exécution d’un test prend 500 ms à 2 secondes. Pour une suite de 50 tests, comptez 3 à 5 minutes au total.
Pour intégrer en CI sur GitHub Actions, deux approches. La voie économique : tests Robolectric en JVM — pas d’émulateur, exécution en 30-60 secondes. La voie complète : Firebase Test Lab ou un runner self-hosted avec émulateur sur runner Linux KVM. Pour 90 % des projets, Robolectric en CI couvre l’essentiel.
name: Tests UI Compose
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew test connectedDebugAndroidTest --no-daemon
Le –no-daemon évite la consommation de RAM résiduelle entre étapes. Sur runner Linux gratuit, l’émulateur n’est pas disponible nativement, mais reactivecircus/android-emulator-runner le configure proprement. Pour les projets sérieux, investir dans cette CI paie dès la dixième PR.
Étape 8 — Capturer des screenshots de référence (golden tests)
Les tests sémantiques (texte, click) vérifient la logique mais pas l’apparence. Pour détecter qu’une régression CSS-like (couleur, espacement, alignement) ne soit introduite, on utilise des screenshot tests. Le principe : capturer une image PNG de l’écran lors d’un test et la comparer pixel par pixel à une référence stockée dans le repo. Toute différence fait échouer la CI et oblige à valider le changement intentionnellement.
@Test
fun rendu_carte_article_correspond_a_la_reference() {
composeTestRule.setContent {
HelloKotlinTheme {
WelcomeCard(title = "Titre", description = "Description")
}
}
composeTestRule.onRoot().captureToImage()
.assertAgainstGolden("welcome_card_default")
}
Plusieurs bibliothèques implémentent ce pattern : Paparazzi (Cash App, le plus populaire, JVM pur, pas d’émulateur), Roborazzi (Robolectric-based), et Shot. Paparazzi est généralement le meilleur choix pour Compose en 2026 : compilation instantanée, comparaison pixel-perfect, et intégration CI directe. Le coût initial : générer les références (golden images) et les versionner dans Git. Le gain : aucune régression visuelle ne passe inaperçue.
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| Test échoue avec « ComposeNode not found » | Composable pas encore rendu | Ajouter composeTestRule.awaitIdle() ou attendre via waitUntil |
| Test flaky avec animations | Compose attend la fin des animations | Désactiver les animations en test |
| Sélecteur non unique | Plusieurs nœuds avec le même texte | Utiliser useUnmergedTree = true ou un testTag |
| Test lent à démarrer | Recompilation Gradle | Lancer plusieurs tests dans la même classe pour partager la setup |
| Hilt non injecté en test | Manque @HiltAndroidTest et HiltAndroidRule | Ajouter les annotations + module de test avec @TestInstallIn |
Étendre la couverture sans alourdir
L’erreur classique : viser 100 % de couverture et passer plus de temps à écrire des tests qu’à écrire le code. Mieux vaut couvrir 60 % des chemins critiques que 95 % de tout. Concrètement, pour une application typique : test unitaire de chaque ViewModel (succès, erreur, état vide), un test de DAO par requête complexe, un test UI par écran majeur (au moins le rendu de chaque UiState possible), un test de parcours pour les flows critiques (login, achat, soumission de formulaire). Soit en moyenne 20 à 40 tests pour une application de 8 écrans. Une nouvelle fonctionnalité s’accompagne idéalement d’au moins un test par état UiState qu’elle ajoute.
Pour mesurer la couverture, JaCoCo s’intègre via le plugin Gradle kover (JetBrains). Il génère un rapport HTML qui pointe les lignes non testées. Objectif raisonnable : 70 % sur les couches métier (ViewModel, Repository, mappers), 50 % sur la couche UI Compose, ignorer la couche réseau pure (déjà testée par Retrofit/OkHttp). Pour aller plus loin, configurer kover avec un seuil minimal qui fait échouer la build en dessous d’un certain pourcentage, et publier le rapport en artifact CI pour suivi historique des tendances par sprint.
Foire aux questions
Espresso ou Compose Test ?
Compose Test pour tester du Compose, Espresso reste utile uniquement si vous avez encore des Views XML dans l’application.
Robolectric ou émulateur ?
Robolectric pour la CI rapide et pour les développeurs qui veulent itérer vite. Émulateur pour valider en conditions réelles avant release.
Comment tester des animations ?
Désactiver via SnapshotStateObserver ou utiliser composeTestRule.mainClock.autoAdvance = false pour piloter le temps manuellement.
Faut-il tester chaque composable ?
Non. Tester les composables stateful et les écrans complets. Les petits composables stateless n’apportent pas de valeur de test.
Comment tester un AlertDialog ?
Le dialogue est rendu dans une fenêtre séparée. Utiliser onNodeWithText sur le bouton qui le déclenche, puis onNodeWithText(« Confirmer ») sur le bouton du dialogue.
Test instrumenté ou unitaire ?
Unitaire JVM dès que possible : 10× plus rapide. Instrumenté seulement quand on a besoin de l’environnement Android complet.
Paparazzi ou Roborazzi ?
Paparazzi pour Compose mono-plateforme. Roborazzi si le projet utilise déjà Robolectric.
Comment éviter les tests flaky ?
Désactiver les animations, utiliser waitForIdle() avant les assertions, et préférer waitUntil aux Thread.sleep.
Pour aller plus loin
Les tests automatiques en place, l’application est prête pour la production. L’étape finale consiste à packager et publier sur le Play Store. Le tutoriel Publier une application Kotlin sur le Play Store détaille la création du keystore, le build .aab signé, et l’upload sur Play Console. Pour la vue panoramique, voir le guide principal Kotlin et Jetpack Compose.