Pourquoi votre suite Appium passe au rouge à chaque refonte
Vos tests passaient vendredi. Le design a livré une nouvelle navigation lundi. Quarante tests sont maintenant rouges, aucune des fonctionnalités sous-jacentes n'est cassée, et quelqu'un passe deux jours à réécrire des sélecteurs.
Ce n'est pas de l'instabilité — un test instable échoue au hasard. Ceux-ci échouent de façon déterministe, à chaque exécution, jusqu'à ce qu'un humain les modifie. C'est un problème de conception dans la manière dont les tests trouvent les choses, et il existe une liste de correctifs.
Trier d'abord : sélecteur cassé ou vraie régression ?
Séparez les deux avant de toucher à quoi que ce soit :
- Échec sur
findElement, la capture montre le bon écran → problème de sélecteur. L'application va bien ; c'est le test qui ne la voit pas. - Échec sur une assertion, ou la capture montre le mauvais écran → vraie régression.
Si votre suite ne prend pas de capture d'écran à l'échec, corrigez cela en premier. Sans elle, tous les échecs se ressemblent et vous retriez les mêmes quarante tests à la main à chaque version.
Capturez aussi la source de la page, pas seulement la capture d'écran
Une capture d'écran vous dit à quoi l'écran ressemblait. La source de la page vous dit quels localisateurs existaient à cet instant — c'est-à-dire ce dont vous avez réellement besoin pour écrire le correctif. Joignez-la au rapport et la plupart des sélecteurs cassés se corrigent en une passe, sans relance :
public class DumpOnFailure implements TestWatcher {
@Override
public void testFailed(ExtensionContext ctx, Throwable cause) {
AppiumDriver driver = DriverHolder.get();
Allure.addAttachment("page-source.xml", "text/xml", driver.getPageSource());
Allure.addAttachment("screen.png", new ByteArrayInputStream(
driver.getScreenshotAs(OutputType.BYTES)));
}
}Sans elle, la boucle est : lire l'échec → relancer en local → reproduire → inspecter → corriger. Avec elle, la boucle est : ouvrir le rapport → lire l'arbre → corriger. Sur une suite qui casse quarante tests par refonte, cette différence vaut une après-midi entière.
Sur un rejeu de parcours en échec ici, vous obtenez une version plus forte de la même chose : l'arbre tel qu'enregistré et l'arbre tel qu'il était en direct, plus un score de similarité, pour que la différence pointe directement ce qui a bougé au lieu de vous laisser comparer deux vidages XML à l'œil.
1. Cessez de dériver les localisateurs à la main
La plupart des sélecteurs fragiles existent parce que quelqu'un a lu un vidage d'interface et a deviné. Cette supposition est généralement positionnelle :
// A bet that nobody ever inserts a view above this one
driver.findElement(By.xpath("//android.widget.LinearLayout[2]/android.widget.Button[1]"));RobotActions inspecte l'élément à votre place et renvoie une liste classée de stratégies —
id, text, content-desc, sélecteur d'accessibilité, puis XPath en dernier recours.
L'inspecteur de l'interface affiche cette liste, et le même classement est accessible à un agent
via MCP :
device_locators_for(udid, text: "Check out")
→ id: com.myapp:id/checkout_button
content-desc: checkout-button
text: Check out
xpath: //android.widget.Button[@resource-id='com.myapp:id/checkout_button']
Vous choisissez en haut de cette liste au lieu de dériver depuis le bas. Le classement est le conseil : tout ce qui se trouve sous content-desc est un localisateur que vous modifierez à nouveau.
« Élément introuvable » veut souvent dire « pas encore atteint par défilement »
Un piège propre à Android qui envoie les gens droit vers du mauvais XPath : la source de la page
ne contient que les nœuds rendus. Un élément sous la ligne de flottaison est réellement absent
du vidage, si bien qu'un findElement échoue et qu'on croit le localisateur faux. Il ne l'est
pas — l'élément n'a simplement pas encore été disposé.
La plupart des suites répondent à cela par une boucle maison : balayer vers le haut, sonder, balayer encore, abandonner au bout de N tentatives. Cette boucle est elle-même une source d'instabilité — elle dépend de la distance de balayage, de la hauteur de la liste et de la physique du défilement, qui changent toutes avec l'appareil et avec la refonte.
device_scroll_to_element fait cela en une seule étape sur Android et iOS : nommez la cible, et
l'appareil défile jusqu'à ce qu'elle soit à l'écran, puis agit dessus. Supprimez les boucles.
Donnez une chaîne de repli aux éléments importants
Pour la poignée d'éléments dont dépend toute la suite — paiement, connexion, l'appel à l'action principal — ne misez pas sur une stratégie unique. Essayez-les par ordre de stabilité et prenez la première qui se résout :
private WebElement findFirst(By... strategies) {
for (By by : strategies) {
try { return driver.findElement(by); }
catch (NoSuchElementException ignored) { /* try the next strategy */ }
}
throw new NoSuchElementException("no strategy matched: " + Arrays.toString(strategies));
}
// Ordered exactly like the ranked list above
WebElement checkout = findFirst(
AppiumBy.accessibilityId("checkout-button"),
AppiumBy.id("com.myapp:id/checkout_button"),
AppiumBy.androidUIAutomator("new UiSelector().text(\"Check out\")"));Désormais, une refonte qui renomme l'identifiant de ressource retombe sur l'identifiant d'accessibilité, et un écran qui perd les deux se résout encore par le texte visible. Le test ne passe au rouge que lorsque le bouton a réellement disparu.
Deux précautions. Journalisez la stratégie qui a fonctionné — un repli silencieux masque la dégradation jusqu'à ce que chaque élément tourne sur son dernier recours. Et placez le texte en dernier, car c'est celui qui casse à la traduction.
2. Ce qu'il faut demander à l'équipe applicative
Les localisateurs ne peuvent être que dans la mesure de ce que l'application expose. C'est la partie que la QA ne peut pas faire seule, et la demande est modeste :
Des identifiants de ressource uniques et stables. Pas button1 dans neuf écrans. Des
identifiants dupliqués ramènent le XPath positionnel dans votre suite, c'est-à-dire exactement ce
que vous cherchez à éliminer.
Des identifiants d'accessibilité sur chaque élément interactif. Un seul localisateur
fonctionne alors sur les deux plateformes — accessibility_id correspond à contentDescription
sur Android et à accessibilityIdentifier sur iOS :
// Jetpack Compose
Button(onClick = ::checkout,
modifier = Modifier.semantics { contentDescription = "checkout-button" }
) { Text("Check out") }// SwiftUI
Button("Check out") { checkout() }.accessibilityIdentifier("checkout-button")// React Native — testID maps to both platforms
<Pressable testID="checkout-button" onPress={checkout}><Text>Check out</Text></Pressable>Une convention de nommage, arrêtée avant d'en avoir trois cents. Nommez le rôle, pas le
design : checkout-button, jamais green-cta-v2. Un nom tiré du design est assuré d'être faux
après la prochaine refonte.
Traitez ces chaînes comme une surface d'API. En renommer une est un changement cassant et passe en revue comme n'importe quel autre. Sans cette règle, elles se font « ranger » et vous revenez au point de départ.
Le bénéfice secondaire n'est pas mince : un écran que vous ne pouvez pas localiser par identifiant d'accessibilité est généralement un écran difficile à utiliser au lecteur d'écran.
3. Trouvé n'est pas la même chose qu'atteignable
Une large catégorie de « le test a cliqué mais rien ne s'est passé » vient d'éléments qui existent dans l'arbre mais ne peuvent pas réellement être touchés :
- des limites partiellement ou totalement hors de la zone visible
- un nœud de taille nulle ou replié
- quelque chose dessiné par-dessus — un pied de page collant, une notification éphémère, une feuille inférieure
- un élément de classe bouton n'exposant aucune action de clic
findElement réussit dans chacun de ces cas. L'appui « réussit » aussi. L'échec se manifeste
trois étapes plus loin par un écran manquant, et vous allez chercher au mauvais endroit.
Restreignez votre requête à ce qui est réellement à l'écran — device_elements_in_region renvoie
les éléments d'une région plutôt que l'arbre entier — et vérifiez les limites avant d'agir, pas
après.
Il existe un raccourci que la plupart des équipes manquent. Lancez l'audit d'accessibilité sur l'écran et lisez-le comme un rapport d'instabilité, car ces vérifications portent sur les mêmes défauts :
| Constat de l'audit | Pourquoi votre test est instable |
|---|---|
DuplicateClickableBounds | Plusieurs éléments cliquables partagent des limites identiques — votre localisateur peut désigner le mauvais, de façon non déterministe |
TouchTargetSize | Cible tactile sous le minimum recommandé : un appui au centre peut atterrir à côté |
MissingClickAction | Élément de classe bouton n'exposant aucune action de clic — trouvé, touché, sans effet |
SpeakableTextPresent | Contrôle actionnable sans texte ni description — rien de stable pour le localiser, d'où le recours au XPath |
Cette dernière ligne est exactement la boucle dont parle tout cet article : un contrôle sans nom accessible impose un localisateur positionnel, et un localisateur positionnel casse à la prochaine refonte. Corriger le constat d'audit corrige le test et l'expérience au lecteur d'écran d'un seul geste.
4. Un seul script, les deux plateformes
Maintenir des suites iOS et Android parallèles double le coût des refontes sans aucun bénéfice. Deux choses rendent un script unique praticable :
Utilisez les identifiants d'accessibilité comme stratégie principale. accessibility_id
correspond à contentDescription sur Android et à accessibilityIdentifier sur iOS : la même
ligne se résout donc sur les deux — à condition que l'équipe applicative utilise la même chaîne
des deux côtés. Actez-le dans la convention de nommage, sinon vous obtenez deux conventions et
aucune réutilisation.
Ne branchez que là où les plateformes diffèrent réellement, et gardez le branchement minuscule :
private static final boolean IOS =
Platform.fromCapabilities(driver.getCapabilities()) == Platform.IOS;
// Same intent, different system affordance — back navigation
if (IOS) driver.navigate().back(); // swipe / nav bar
else ((AndroidDriver) driver).pressKey(new KeyEvent(AndroidKey.BACK));Limitez ces branchements à l'habillage système — retour, autorisations, fermeture du clavier, feuilles de partage. Si vous vous surprenez à brancher sur un flux métier, c'est une divergence produit à signaler plutôt qu'un problème de test à masquer.
Les verbes de pilotage d'appareil sont ici délibérément symétriques — trouver, attendre, défiler jusqu'à, toucher par libellé existent sur les deux plateformes avec la même sémantique — de sorte que les parties du script qui ne relèvent pas de l'habillage système restent identiques.
5. Remplacez les pauses par des attentes qui décrivent une condition
Thread.sleep(3000) est un pari sur le fait que l'appareil est aussi rapide aujourd'hui qu'au
moment où vous l'avez écrit. Il échoue au démarrage à froid, sur un réseau lent ou sur un
exécuteur de CI chargé — et il gaspille trois secondes à chaque exécution où l'application était
prête immédiatement.
// Explicit wait — fails fast, passes fast
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.elementToBeClickable(checkoutButton));
// Fluent wait — poll interval and ignored exceptions under your control
new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(15))
.pollingEvery(Duration.ofMillis(250))
.ignoring(NoSuchElementException.class, StaleElementReferenceException.class)
.until(d -> d.findElement(checkout).isDisplayed());Ignorer StaleElementReferenceException compte plus qu'on ne le croit : pendant une animation,
l'élément peut être trouvé, puis remplacé en plein sondage. Sans cela, vous obtenez un échec qui
ressemble à un élément manquant sans en être un.
La même condition existe comme étape de premier ordre quand on pilote un appareil ici —
device_wait_for_element attend la fin de l'animation au lieu d'en deviner la durée.
6. Supprimez les interruptions
Une large part des échecs mobiles « aléatoires » vient de l'OS qui interrompt : une demande d'autorisation, une bannière de mise à jour système, une invitation à noter l'application. Traitez-les structurellement au lieu d'ajouter un try/catch par écran. Les sessions pilotées par navigateur de RobotActions positionnent ceci sur la session sous-jacente :
{ "appium:autoAcceptAlerts": true, "appium:autoDismissAlerts": true }Choisissez délibérément — autoAccept accorde les autorisations, ce qui est généralement ce que
vous voulez pour une suite de chemin nominal, mais cela signifie aussi que vous ne testez plus
le chemin de refus. Gardez au moins un test avec les deux désactivés, qui exerce le comportement
en cas d'autorisation refusée.
7. Enregistrez le parcours, exportez l'objet de page
Le moyen le plus fiable de cesser d'écrire des localisateurs à la main est de ne pas en écrire.
Parcourez le flux une fois sur un appareil réel et exportez-le : vous obtenez un Page Object avec
des annotations @FindBy déjà remplies à partir des localisateurs classés, un WebDriverWait, et
des utilitaires waitAndClick / waitAndSendKeys — les attentes câblées dès le départ plutôt que
rajoutées après la première exécution instable.
C'est la différence qui mérite d'être mesurée. La plupart des clouds d'appareils vous donnent un écran distant et vous laissent la stratégie de localisation, les attentes et la gestion des interruptions sur les bras. Ici, l'inspecteur, le classement des localisateurs, l'enregistreur et le code exporté forment un même système : les sélecteurs de votre suite sont donc ceux que la plateforme a déjà jugés les plus stables.
8. Quand il n'y a aucune prise stable
Parfois il n'y a rien à saisir : une vue de paiement tierce, une WebView que vous ne contrôlez pas, un écran où ajouter des identifiants n'est à la feuille de route de personne. Décrivez alors l'intention plutôt que la structure :
Ouvre l'application, ajoute un produit au panier et finalise la commande avec la carte enregistrée.
Un agent IA prend cette histoire utilisateur, pilote un appareil Android ou iOS réel, et
trouve les éléments d'après ce qui est à l'écran plutôt que d'après un identifiant interne. Un
identifiant renommé ou une nouvelle vue englobante ne change pas l'apparence de l'écran :
l'exécution continue de fonctionner — et lorsqu'elle ne peut réellement pas poursuivre, vous
obtenez une capture d'écran et une trace de l'endroit où elle s'est arrêtée, pas une
NoSuchElementException pointant vers un sélecteur.
Rien n'a besoin d'être réécrit pour essayer. Pointez votre suite Appium existante vers la grille, gardez tous les tests que vous avez écrits, et laissez l'agent prendre en charge la poignée de parcours qui cassent à chaque refonte.
La liste de contrôle
- Captures d'écran et source de page à l'échec — tout le reste dépend de la capacité à trier sans relancer.
grep -rn "XPath.*\[[0-9]" tests/— cette liste est votre arriéré, classé par fréquence de modification.- Convertissez les dix premiers en suivant la liste classée de localisateurs, de haut en bas.
- Une pull request vers l'application pour les identifiants d'accessibilité manquants, plus la convention de nommage.
- Supprimez chaque
Thread.sleepet remplacez-le par une attente explicite ou fluide. - Activez le traitement automatique des alertes, et gardez un test qui ne le fait délibérément pas.
- Donnez une chaîne de repli à vos cinq éléments les plus sollicités, et journalisez la stratégie qui a fonctionné.
- Supprimez les boucles maison de défilement-jusqu'à-visible au profit d'une seule étape de défilement vers l'élément.
- Lancez l'audit d'accessibilité sur vos trois écrans les plus instables et traitez
DuplicateClickableBoundsetSpeakableTextPresentcomme des bugs de test, pas comme des agréments d'accessibilité.
Prêt à tester sur de vrais appareils ?
Connectez-vous avec Google ou GitHub et accédez à de vrais appareils iOS et Android depuis votre navigateur — gratuit à l'essai.