Tester votre mise en page Compose

Les interfaces utilisateur ou les écrans de test permettent de vérifier que votre code Compose se comporte correctement et ainsi d'améliorer la qualité de votre application en identifiant les erreurs dès le début du processus de développement.

Compose fournit un ensemble d'API de test pour trouver des éléments, vérifier leurs attributs et effectuer des actions utilisateur. D'autres fonctionnalités avancées, telles que la manipulation du temps, sont également proposées.

Sémantique

Dans Compose, les tests de l'interface utilisateur utilisent la sémantique pour interagir avec la hiérarchie de l'interface utilisateur. Comme son nom l'indique, la sémantique donne du sens à une interface utilisateur. Dans ce contexte, un "élément d'interface utilisateur" (ou élément) peut prendre toutes les formes, d'un composable unique à un plein écran. L'arborescence sémantique est générée parallèlement à la hiérarchie de l'interface utilisateur et la décrit.

Schéma d'une mise en page d'interface utilisateur classique, avec détail de la manière de faire correspondre cette mise en page avec une arborescence sémantique correspondante

Image 1. Hiérarchie classique de l'UI avec son arborescence sémantique.

Le framework sémantique étant principalement utilisé pour l'accessibilité, les tests exploitent les informations exposées par la sémantique concernant la hiérarchie de l'interface utilisateur. Les développeurs décident de ce qu'ils exposent et en quelle quantité.

Bouton contenant un graphique et du texte

Image 2. Bouton type contenant une icône et du texte.

Par exemple, pour un bouton de ce type composé d'une icône et d'un élément de texte, l'arborescence sémantique par défaut ne contient que la mention "J'aime". En effet, certains composables, comme Text, exposent déjà certaines propriétés à l'arborescence sémantique. Vous pouvez ajouter des propriétés à l'arborescence sémantique à l'aide d'un Modifier.

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

Configuration

Cette section explique comment configurer votre module pour tester le code Compose.

Commencez par ajouter les dépendances suivantes au fichier build.gradle du module contenant vos tests d'interface utilisateur :

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createAndroidComposeRule, but not createComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

Ce module inclut une ComposeTestRule et une intégration pour Android appelée AndroidComposeTestRule. Cette règle vous permet de configurer du contenu Compose ou d'accéder à l'activité. Voici à quoi ressemble un test d'interface utilisateur pour Compose :

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    // use createAndroidComposeRule<YourActivity>() if you need access to
    // an activity

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

API de test

Il existe trois façons principales d'interagir avec les éléments :

  • Les outils de recherche permettent de sélectionner un ou plusieurs éléments (ou nœuds de l'arborescence sémantique) pour définir des assertions ou effectuer des actions par rapport à eux.
  • Les assertions permettent de vérifier que les éléments existent ou qu'ils possèdent certains attributs.
  • Les actions ajoutent des événements utilisateur simulés sur les éléments tels que des clics ou d'autres gestes.

Certaines de ces API acceptent un SemanticsMatcher pour faire référence à un ou plusieurs nœuds de l'arborescence sémantique.

Outils de recherche

Vous pouvez utiliser onNode et onAllNodes pour sélectionner un ou plusieurs nœuds, mais vous pouvez également utiliser des paramètres de recherche définis pour les recherches les plus courantes, comme onNodeWithText, onNodeWithContentDescription, etc. Retrouvez-en la liste complète dans l'aide-mémoire pour les tests Compose.

Sélectionner un seul nœud

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

Sélectionner plusieurs nœuds

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

Utiliser l'arborescence non fusionnée

Certains nœuds fusionnent les informations sémantiques de leurs enfants. Par exemple, un bouton avec deux éléments de texte fusionne leurs étiquettes :

MyButton {
    Text("Hello")
    Text("World")
}

À partir d'un test, nous pouvons utiliser printToLog() pour afficher l'arborescence sémantique :

composeTestRule.onRoot().printToLog("TAG")

Ce code imprime le résultat suivant :

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

Si vous devez faire correspondre un nœud de l'arborescence non fusionnée, vous pouvez définir useUnmergedTree sur true :

composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")

Ce code imprime le résultat suivant :

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = '[Hello]'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = '[World]'

Le paramètre useUnmergedTree est disponible dans tous les outils de recherche. Il est par exemple utilisé dans un outil de recherche onNodeWithText.

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

Assertions

Vérifiez les assertions en appelant assert() sur le SemanticsNodeInteraction renvoyé par un outil de recherche avec une ou plusieurs correspondances :

// Single matcher:
composeTestRule
    .onNode(matcher)
    .assert(hasText("Button")) // hasText is a SemanticsMatcher

// Multiple matchers can use and / or
composeTestRule
    .onNode(matcher).assert(hasText("Button") or hasText("Button2"))

Vous pouvez également utiliser des fonctions définies pour les assertions les plus courantes, comme assertExists, assertIsDisplayed ou assertTextEquals, etc. Retrouvez la liste complète dans l'aide-mémoire pour les tests Compose.

Il existe aussi des fonctions permettant de vérifier les assertions sur un ensemble de nœuds :

// Check number of matched nodes
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())

Actions

Pour ajouter une action sur un nœud, appelez une fonction perform…() :

composeTestRule.onNode(...).performClick()

Voici quelques exemples d'actions :

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

Retrouvez-en la liste complète dans l'aide-mémoire pour les tests Compose.

Outils de mise en correspondance

Cette section décrit certains des outils de mise en correspondance disponibles pour tester votre code Compose.

Outils de mise en correspondance hiérarchique

Les outils de mise en correspondance hiérarchique vous permettent de monter ou de descendre dans l'arborescence sémantique et d'effectuer une mise en correspondance simple.

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

Voici quelques exemples de mise en correspondance :

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

Sélecteurs

Pour créer des tests, vous pouvez aussi utiliser des sélecteurs, qui permettent de les rendre plus lisibles.

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

Retrouvez-en la liste complète dans l'aide-mémoire pour les tests Compose.

Synchronisation

Les tests Compose sont synchronisés par défaut avec votre interface utilisateur. Lorsque vous appelez une assertion ou une action via ComposeTestRule, le test est préalablement synchronisé, en attendant que l'arborescence de l'interface utilisateur soit inactive.

En général, aucune action n'est requise de votre part. Cependant, il existe des situations à connaître.

Lorsqu'un test est synchronisé, votre application Compose est avancée dans le temps à l'aide d'une horloge virtuelle. Les tests Compose ne s'exécutent donc pas en temps réel et peuvent être aussi rapides que possible.

Toutefois, si vous n'utilisez pas les méthodes de synchronisation, aucune recomposition n'est effectuée et l'interface utilisateur semble être interrompue.

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions
    var lastSeenValue = 0 // Used to track recompositions
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition

    // Fails because nothing triggered a recomposition
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition
    composeTestRule.onNodeWithText("1").assertExists()
}

Il est également important de noter que cette exigence ne s'applique qu'aux hiérarchies Compose, et non au reste de l'application.

Désactiver la synchronisation automatique

Lorsque vous appelez une assertion ou une action via ComposeTestRule, par exemple assertExists(), votre test est synchronisé avec l'interface utilisateur de Compose. Dans certains cas, vous pouvez arrêter cette synchronisation et contrôler vous-même l'horloge. Par exemple, vous pouvez contrôler le moment auquel effectuer des captures d'écran précises d'une animation quand l'interface utilisateur est occupée. Pour désactiver la synchronisation automatique, définissez la propriété autoAdvance de la mainClock sur false :

composeTestRule.mainClock.autoAdvance = false

En général, vous avancerez vous-même le temps. Vous pouvez faire défiler exactement une image avec advanceTimeByFrame() ou une durée spécifique avec advanceTimeBy() :

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

Ressources inactives

Compose peut synchroniser les tests et l'interface utilisateur afin que chaque action et assertion soit effectuée dans un état inactif, en attendant ou en avançant l'horloge selon le besoin. Cependant, certaines opérations asynchrones dont les résultats affectent l'état de l'interface utilisateur peuvent être exécutées en arrière-plan sans que le test ne les prenne en compte.

Vous pouvez créer et enregistrer ces ressources inactives dans votre test afin de les prendre en compte lorsque vous décidez si l'application testée est occupée ou inactive. Aucune action n'est requise de votre part, sauf si vous devez enregistrer des ressources d'inactivité supplémentaires, par exemple si vous exécutez une tâche en arrière-plan qui n'est pas synchronisée avec Espresso ou Compose.

Cette API est très semblable aux ressources d'inactivité d'Espresso, qui indiquent si le sujet testé est inactif ou occupé. Vous utilisez la règle de test Compose pour enregistrer la mise en œuvre de IdlingResource.

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

Synchronisation manuelle

Dans certains cas, vous devez synchroniser l'interface utilisateur de Compose avec d'autres parties de votre test ou avec l'application que vous testez.

waitForIdle attend que Compose soit inactif, mais cela dépend de la propriété autoAdvance :

composeTestRule.mainClock.autoAdvance = true // default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for Idling Resources to become idle

Notez que dans les deux cas, waitForIdle attend également les transferts de dessins et de mises en page en attente.

Vous pouvez aussi avancer le temps jusqu'à ce qu'advanceTimeUntil() remplisse une certaine condition.

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

Notez que la condition précisée doit vérifier l'état pouvant être affecté par cette horloge (elle ne fonctionne qu'avec l'état Compose). Toute condition qui dépend d'une mesure ou d'un dessin d'Android (c'est-à-dire, d'une mesure ou d'un dessin externe à Compose) doit utiliser un concept plus général comme waitUntil() :

composeTestRule.waitUntil(timeoutMs) { condition }

Schémas courants

Cette section décrit certaines approches courantes utilisées dans les tests Compose.

Effectuer un test isolé

ComposeTestRule vous permet de lancer une activité affichant n'importe quel composable : votre application complète, un seul écran ou un petit élément. Il est également recommandé de vérifier que vos composables sont correctement encapsulés et fonctionnent de manière autonome afin de tester l'interface utilisateur plus facilement et plus précisément.

Cela ne signifie pas que vous devez uniquement créer des tests d'interface utilisateur. Il est également très important de tester la portée de plus grandes parties de votre interface utilisateur.

Accédez à l'activité et aux ressources après avoir défini votre propre contenu

Vous devez souvent configurer le contenu testé à l'aide de composeTestRule.setContent et accéder aux ressources d'activité, par exemple pour affirmer qu'un texte affiché correspond à une ressource de chaîne. Toutefois, vous ne pouvez pas appeler setContent sur une règle créée avec createAndroidComposeRule() si l'activité l'appelle déjà.

Pour ce faire, il est courant de créer une AndroidComposeTestRule à l'aide d'une activité vide (comme ComponentActivity).

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, /*...*/)
            }
        }
        val continueLabel = composeTestRule.activity.getString(R.string.next)
        composeTestRule.onNodeWithText(continueLabel).performClick()
    }
}

Notez que ComponentActivity doit être ajouté au fichier AndroidManifest.xml de votre application. Pour ce faire, ajoutez la dépendance suivante à votre module :

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

Propriétés de la sémantique personnalisée

Vous pouvez créer des propriétés sémantiques personnalisées pour soumettre des informations aux tests. Pour ce faire, définissez une nouvelle SemanticsPropertyKey et rendez-la disponible à l'aide de SemanticsPropertyReceiver.

// Creates a Semantics property of type boolean
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

Vous pouvez maintenant utiliser cette propriété à l'aide du modificateur semantics :

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

À partir des tests, vous pouvez utiliser SemanticsMatcher.expectValue pour réclamer la valeur de la propriété :

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

Vérifier la restauration d'état

Vérifiez que l'état de vos éléments Compose est correctement restauré lorsque l'activité ou le processus est recréé. Il est possible d'effectuer cette vérification sans dépendre de la recréation d'activité avec la classe StateRestorationTester.

Cette classe vous permet de simuler la recréation d'un composable. Elle s'avère particulièrement utile pour vérifier la mise en œuvre de rememberSaveable.


class MyStateRestorationTests {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)

        restorationTester.setContent { MainScreen() }

        // TODO: Run actions that modify the state

        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()

        // TODO: Verify that state has been correctly restored.
    }
}

Débogage

La principale façon de résoudre les problèmes de vos tests consiste à examiner l'arborescence sémantique. Vous pouvez imprimer l'arborescence en appelant composeTestRule.onRoot().printToLog() à tout moment de votre test. Cette fonction imprime un journal de la manière suivante :

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hi'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'There'

Ces journaux contiennent des informations utiles pour identifier les bugs.

Interopérabilité avec Espresso

Dans une application hybride, vous pouvez trouver des composants Compose dans des hiérarchies de vues et des vues dans des composables Compose (via le composable AndroidView).

Aucune étape particulière n'est requise pour correspondre à chaque type. Vous faites correspondre les vues via l'onView d'Espresso et les éléments Compose via la ComposeTestRule.

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

Interopérabilité avec UiAutomator

Par défaut, les composables ne sont accessibles depuis UiAutomator que par leurs descripteurs pratiques (texte affiché, description du contenu, etc.). Si vous souhaitez accéder à n'importe quel composable qui utilise Modifier.testTag, vous devez activer la propriété sémantique testTagAsResourceId pour cette sous-arborescence de composables. Ce comportement est utile pour les composables qui ne possèdent pas d'autre identifiant unique, comme les composables à faire défiler (par exemple, LazyColumn).

Vous ne pouvez l'activer qu'une seule fois au sommet de la hiérarchie de vos composables afin de vous assurer que tous les composables imbriqués avec Modifier.testTag sont accessibles depuis UiAutomator.

Scaffold(
    // Enables for all composables in the hierarchy.
    modifier = Modifier.semantics {
        testTagsAsResourceId = true
    }
){
    // Modifier.testTag is accessible from UiAutomator for composables nested here.
    LazyColumn(
        modifier = Modifier.testTag("myLazyColumn")
    ){
        // content
    }
}

Tout composable avec Modifier.testTag(tag) est accessible à l'aide de By.res(resourceName) avec le même tag que resourceName.

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

En savoir plus

Pour en savoir plus, consultez l'atelier de programmation Jetpack Compose.

Exemples