Tests mobiles sans script en JSON : un seul fichier pour Android et iOS
Réponse courte : votre application a renommé « Sign in » en « Log in », et chaque test
enregistré qui touche ce bouton échoue maintenant sur Android, puis sur iOS. Un test conservé sous
forme d'enregistrement ne se corrige en général qu'en l'enregistrant à nouveau. Nous conservons
plutôt un test mobile dans un fichier JSON : des actions et des sélecteurs, aucune coordonnée.
Vous pouvez l'écrire à la main, le faire écrire par un agent IA, le relire dans une pull request
et le corriger en modifiant une ligne. Avec "platform": "mobile", le même fichier s'exécute sur
un téléphone Android et sur un iPhone.
Cet article est la référence que nous aurions voulu avoir en le construisant : la forme du format, un extrait pour chaque action, la façon dont les deux plateformes partagent un seul fichier, et ce que répond le validateur quand quelque chose ne va pas. Chaque extrait ci-dessous a été vérifié avec le validateur en production avant publication.
Pourquoi les enregistrements se dégradent et pas les fichiers
Un test enregistré contient en général trois choses qui cassent : une position à l'écran pour chaque toucher, un instantané de l'écran sur lequel il a été enregistré, et un modèle d'appareil qu'il suppose sans le dire. Changez la mise en page, le téléphone ou l'OS, et l'enregistrement ne correspond plus.
Un flux JSON ne garde rien de tout cela. Chaque étape désigne un élément par ce qu'une personne en dirait (son texte ou son identifiant), et chaque exécution cherche cet élément sur l'écran tel qu'il est à cet instant. Rien de ce qui a été enregistré sur Android n'est jamais comparé à un iPhone, puisque rien n'est enregistré du tout.
Trois conséquences pratiques :
- Vous pouvez le lire. Dans un diff, la personne qui relit voit
tap "Add to cart", pas un bloc binaire. - Vous pouvez le corriger. Un bouton renommé, c'est un mot à changer, pas un nouvel enregistrement.
- Tout le monde peut l'écrire. Une testeuse dans son éditeur, un script qui le génère à partir d'un tableur ou un agent IA qui vient d'observer l'application produisent tous le même fichier.
Le construire en quatre gestes
- Commencez par
launchApp. Nommez l'application, pour que l'exécution démarre toujours sur un écran connu. - Ajoutez une étape par geste d'une personne, et désignez chaque élément par ce qui est à l'écran : le mot sur le bouton, le libellé du champ. Pas de coordonnées, pas de tailles d'écran, pas d'attente pour les animations.
- Validez. Chaque erreur nomme l'étape, le champ et la correction : vous ne devinez jamais ce que le validateur attendait.
- Importez-le et rejouez-le sur un téléphone Android et un iPhone.
Si vous savez décrire le test à un collègue, vous savez l'écrire. Un agent IA peut aussi l'écrire, à partir de la même description.
Le tout sur un seul écran
Voici un flux complet qui se connecte, trouve un produit et l'ajoute au panier, sur l'un ou l'autre téléphone :
{
"name": "Sign in and add to cart",
"platform": "mobile",
"steps": [
{ "action": "launchApp", "appId": { "android": "com.example.shop", "ios": "com.example.Shop" } },
{ "action": "tap", "selectors": [{ "using": "id", "value": "email_input" }, { "using": "text", "value": "Email" }] },
{ "action": "type", "value": "{{secret.SHOP_EMAIL}}" },
{ "action": "tap", "selectors": [{ "using": "id", "value": "password_input" }, { "using": "text", "value": "Password" }] },
{ "action": "type", "value": "{{secret.SHOP_PASSWORD}}" },
{ "action": "tap", "selectors": [{ "using": "text", "value": "Sign in" }] },
{ "action": "assert", "assert": { "kind": "exists" }, "selectors": [{ "using": "text", "value": "Home" }], "timeoutMs": 15000 },
{ "action": "scroll", "selectors": [{ "using": "text", "value": "Running shoes" }] },
{ "action": "tap", "selectors": [{ "using": "text", "value": "Running shoes" }] },
{ "action": "tap", "selectors": [{ "using": "text", "value": "Add to cart" }] },
{ "action": "assert", "assert": { "kind": "textContains", "expected": "1 item" }, "selectors": [{ "using": "id", "value": "cart_badge" }] }
]
}Onze étapes, pas de code, pas de coordonnées, et aucun « si Android » nulle part. La suite de l'article décrit ce que chacune de ces lignes peut contenir.
L'enveloppe du flux
| Champ | Valeur | Remarques |
|---|---|---|
name | texte libre | Un nom déjà utilisé crée un second flux. Rien n'est écrasé. |
platform | "android", "ios" ou "mobile" | mobile est un seul flux qui s'exécute sur les deux. |
category | "test" (par défaut) ou "fragment" | Un fragment est un morceau réutilisable, une connexion par exemple. |
tmsLink | facultatif | Relie le flux à son cas de test dans TestRail, Jira ou Azure DevOps. |
steps | tableau | Jusqu'à 500, étapes imbriquées comprises. |
Les sélecteurs : comment une étape trouve son élément
Un sélecteur s'écrit { "using": ..., "value": ... }. Vous pouvez en indiquer plusieurs. Si l'un
ne trouve rien, ou correspond à plus d'un contrôle, le suivant est essayé. La section suivante
explique dans quel ordre.
"selectors": [
{ "using": "id", "value": "com.example.shop:id/checkout" },
{ "using": "text", "value": "Checkout" }
]Trois types fonctionnent sur les deux téléphones :
textest le texte visible ou le libellé d'accessibilité.labelse comporte de la même façon. Prenez le mot qui vous parle le plus.idest le resource-id Android ou l'identifiant d'accessibilité iOS. Si vos développeurs posent le même identifiant sur les deux plateformes, c'est le sélecteur le plus stable que vous ayez.
La comparaison de texte ignore la casse, les espaces superflus et la largeur des caractères, mais
reste exacte pour le reste. "sign IN" trouve « Sign in ». "Network and Internet" ne trouve
pas « Network & internet ».
Quand le libellé varie selon la version, la région ou la version de l'OS, faites du sélecteur un motif. Le texte entier doit correspondre, sans tenir compte de la casse :
{ "using": "text", "value": "(Allow|Allow While Using App|While using the app)", "regex": true }regex ne s'applique qu'à text et label. Gardez des motifs simples : le validateur refuse
ceux qui pourraient bloquer, comme les répétitions imbriquées ou les références arrière.
Quatre autres types de sélecteurs existent. Ils ne sont valides que là où l'étape s'exécute sur un seul OS, comme l'explique la section « Un fichier, deux plateformes » plus bas.
using | Plateforme | Exemple de valeur |
|---|---|---|
accessibilityId | Android. | "Open navigation drawer" |
value | iOS. | "Back" |
class | L'une ou l'autre, dans un bloc de plateforme. | "android.widget.Switch" / "XCUIElementTypeSwitch" |
xpath | L'une ou l'autre, dans un bloc de plateforme. | "//*[@text='Save']" |
Un xpath doit sélectionner par attribut, comme //*[@text='Save']. Un chemin purement
positionnel tel que //LinearLayout[3]/Button[1] est refusé : il décrit où se trouvait l'élément
le jour où vous l'avez écrit.
Une règle qui fait gagner des heures : l'exécution ne choisit jamais au hasard entre deux contrôles. Une ligne « Paramètres » et le texte « Paramètres » qu'elle contient forment un seul contrôle, compté une fois. Cinq boutons « Supprimer » sont cinq contrôles. Si aucun sélecteur ne les ramène à un seul, l'étape échoue en indiquant combien correspondent, et vous ajoutez un sélecteur plus précis. Un test qui touche le premier de cinq boutons « Supprimer » passe, jusqu'au jour où il supprime la mauvaise ligne.
Comment une étape s'exécute sur le téléphone
Rien dans le fichier ne dit où se trouve un élément. Chaque étape qui a des sélecteurs trouve son élément sur l'écran en direct, en cinq temps :
- Lire l'écran. L'exécution lit l'arbre d'éléments du téléphone en direct, celui que voient les services d'accessibilité. Elle le relit toutes les 500 ms jusqu'à ce que l'étape réussisse ou que le délai expire.
- Classer les sélecteurs. Chaque plateforme les essaie dans son propre ordre, toujours le même. Sur Android : d'abord le texte visible, puis le libellé d'accessibilité, puis le resource-id. Sur iPhone : d'abord l'identifiant d'accessibilité, puis le libellé, puis la valeur. L'ordre dans lequel vous les écrivez ne sert qu'à départager.
- Garder un seul contrôle. Un sélecteur qui désigne un seul contrôle l'emporte. Les copies d'un même contrôle comptent pour un. Deux contrôles différents rendent ce sélecteur ambigu, et le suivant est essayé.
- L'amener à l'écran. Une longue liste garde des lignes que l'œil ne voit pas. Si l'élément existe mais est hors écran, l'exécution fait défiler vers lui, jusqu'à cinq fois.
- Agir en son centre. Le toucher tombe au milieu de l'élément tel qu'il est dessiné maintenant, sur ce téléphone, à cette taille d'écran. Un toucher qui ne viserait qu'un grand conteneur, comme la fenêtre entière, est refusé : il passerait sans rien presser.
Si le délai expire (5 secondes, ou le timeoutMs de l'étape), l'étape échoue en disant pourquoi :
l'élément est introuvable, ou plusieurs contrôles correspondent, et elle dit combien. Une étape
optional est simplement sautée.
Voici ce que fait chaque action une fois son élément connu :
| Action | Ce que fait l'exécution |
|---|---|
launchApp | Démarre l'application par son nom de paquet ou son bundle id. Aucune recherche d'élément. |
tap, doubleTap, longPress | Trouve l'élément, puis touche son centre. longPress maintient pendant durationMs. |
type | Aucune recherche. Saisit dans le champ qui a le focus. Un {{secret.KEY}} est rempli pour cette exécution seulement, et l'étape enregistrée garde la référence. |
scroll | Fait défiler jusqu'à ce que l'élément soit à l'écran. |
wait | Fait une pause de la durée indiquée. Rien d'autre. |
assert | Lit l'écran toutes les 500 ms jusqu'à ce que la condition soit vraie ou que le délai expire. |
if | Vérifie sa condition de la même façon, puis exécute then ou else. platformIs ne lit pas l'écran. |
pressButton, key | Appuie sur le bouton matériel ou la touche. Aucune recherche. |
Il s'exécute sur le téléphone, pas à travers une grille. Un flux mobile ne passe pas par la grille Selenium ou Appium qu'utilisent les suites d'automatisation. L'exécution pilote le téléphone directement depuis la machine où il est branché. Pas de hub par lequel transiter, pas de nœud à attendre, pas de file de sessions distantes : rien ne s'interpose entre l'étape et l'écran. Chaque étape, c'est une lecture de l'écran et un geste, et le temps entre deux étapes est celui de l'application, pas celui de l'infrastructure.
Chaque action, avec un extrait
launchApp : lancer l'application testée
{ "action": "launchApp", "appId": { "android": "com.example.shop", "ios": "com.example.Shop" } }Un nom de package et un bundle id sont deux chaînes différentes, donc un flux mobile nomme les
deux. Un flux mono-plateforme peut utiliser une simple chaîne, et Android peut préciser une
activité de lancement :
{ "action": "launchApp", "appId": "com.example.shop", "appActivity": ".MainActivity" }Placez-la en première étape. Sinon, le validateur vous prévient que la relecture démarrera sur l'écran que le téléphone affiche à ce moment-là.
tap
{ "action": "tap", "selectors": [{ "using": "text", "value": "Sign in" }] }doubleTap
{ "action": "doubleTap", "selectors": [{ "using": "id", "value": "product_image" }] }longPress
{ "action": "longPress", "selectors": [{ "using": "text", "value": "Size 42" }], "durationMs": 800 }durationMs est facultatif, de 100 à 10 000.
type : saisir du texte dans le champ actif
{ "action": "tap", "selectors": [{ "using": "text", "value": "Search" }] },
{ "action": "type", "value": "running shoes" }type n'a pas de sélecteur. Il saisit dans le champ qui a le focus, donc touchez d'abord le champ.
C'est l'ordre que suit une personne, et le clavier est ainsi affiché avant l'envoi du texte.
Les mots de passe et autres secrets ne vont jamais dans le fichier. Enregistrez-les une fois dans Variables, puis désignez-les par leur nom :
{ "action": "tap", "selectors": [{ "using": "text", "value": "Password" }] },
{ "action": "type", "value": "{{secret.SHOP_PASSWORD}}" }Si le champ que vous venez de toucher ressemble à un champ de mot de passe et que la valeur est un
texte littéral, le flux est refusé au lieu d'être enregistré. Les flux mobiles n'ont pas de
variables d'environnement : {{secret.KEY}} est la seule forme de référence qu'ils résolvent.
scroll : faire apparaître un élément
{ "action": "scroll", "selectors": [{ "using": "text", "value": "Running shoes" }] }Vous indiquez ce que vous voulez atteindre, pas la distance à faire défiler. L'exécution fait défiler jusqu'à ce que l'élément soit à l'écran, donc l'étape résiste à une liste qui s'allonge ou à un téléphone plus grand.
wait
{ "action": "wait", "value": 1500 }En millisecondes, de 1 à 120 000. À utiliser en dernier recours. Une attente fixe est soit trop
courte sur un réseau lent, soit du temps perdu sur un réseau rapide. Pour attendre un écran,
vérifiez quelque chose qu'il affiche et donnez à cette étape un timeoutMs plus long :
{ "action": "assert", "assert": { "kind": "exists" }, "selectors": [{ "using": "text", "value": "Home" }], "timeoutMs": 15000 }assert : quatre types
{ "action": "assert", "assert": { "kind": "exists" }, "selectors": [{ "using": "text", "value": "Order placed" }] },
{ "action": "assert", "assert": { "kind": "notExists" }, "selectors": [{ "using": "text", "value": "Something went wrong" }] },
{ "action": "assert", "assert": { "kind": "textEquals", "expected": "Payment received" }, "selectors": [{ "using": "id", "value": "order_status" }] },
{ "action": "assert", "assert": { "kind": "textContains", "expected": "1 item" }, "selectors": [{ "using": "id", "value": "cart_badge" }] }textEquals et textContains exigent expected. exists et notExists ne doivent pas en
avoir. La comparaison de texte est la même que celle des sélecteurs : tolérante, mais exacte.
pressButton : boutons physiques
{ "action": "pressButton", "button": "home" }Les boutons sont home, volumeUp, volumeDown et lock, et les quatre fonctionnent sur les
deux téléphones.
key : retour Android
{ "action": "key", "key": "back" }ou n'importe quel code de touche Android :
{ "action": "key", "keyCode": 66 }Les iPhone n'ont pas de touche retour, donc key est réservé à Android. Dans un flux mobile, il
doit se trouver dans un bloc de plateforme, l'objet de la section suivante.
if : bifurquer selon ce qui est à l'écran
{
"action": "if",
"condition": { "check": "exists", "timeoutMs": 3000 },
"selectors": [{ "using": "text", "value": "(Allow|Allow While Using App|While using the app)", "regex": true }],
"then": [
{ "action": "tap", "selectors": [{ "using": "text", "value": "(Allow|Allow While Using App|While using the app)", "regex": true }] }
]
}Ce bloc gère le dialogue d'autorisation qui apparaît après une installation neuve et disparaît à
la deuxième exécution. check accepte les quatre types d'assertion. then et else contiennent
chacun une liste d'étapes, et les blocs s'imbriquent jusqu'à cinq niveaux.
Deux options acceptées par toutes les étapes
{ "action": "tap", "selectors": [{ "using": "text", "value": "Not now" }], "optional": true, "timeoutMs": 2000 }optional: truesaute l'étape quand son élément est absent, au lieu de faire échouer l'exécution. Utile pour une bannière promotionnelle qui n'apparaît que parfois.timeoutMs(de 100 à 120 000) est la durée pendant laquelle l'étape cherche son élément.
Un fichier, deux plateformes
La règle est courte : des sélecteurs partagés par défaut, et un bloc pour les vraies différences.
La plupart des étapes ne mentionnent jamais de plateforme. text et id se résolvent sur les deux
téléphones, et launchApp accepte les deux identifiants d'application. Quand les deux applications
diffèrent vraiment, encadrez cette partie dans platformIs :
{
"action": "if",
"condition": { "check": "platformIs", "expected": "android" },
"then": [ { "action": "key", "key": "back" } ],
"else": [ { "action": "tap", "selectors": [{ "using": "value", "value": "Back" }] } ]
}else désigne l'autre téléphone. Dans chaque branche, le flux est mono-plateforme : l'étape key,
réservée à Android, et le sélecteur value, réservé à iOS, y deviennent permis. Il n'existe pas de
surcharge de plateforme étape par étape. Chaque différence vit dans un bloc visible, ce qui montre
à la personne qui relit exactement où les deux applications divergent.
Le validateur suit l'OS sur lequel chaque étape peut s'exécuter. Une branche qui ne peut jamais
s'exécuter est une erreur, par exemple un bloc ios dans un flux réservé à Android. Un sélecteur
propre à une plateforme placé hors d'un bloc l'est aussi.
Ce que répond le validateur quand vous vous trompez
Voici un flux qui contient quatre erreurs :
{
"name": "Broken on purpose",
"platform": "mobile",
"steps": [
{ "action": "launchApp", "appId": "com.example.shop" },
{ "action": "tap", "selectors": [{ "using": "accessibilityId", "value": "login_button" }] },
{ "action": "tap", "selectors": [{ "using": "text", "value": "Password" }] },
{ "action": "type", "value": "hunter2" },
{ "action": "key", "key": "back" }
]
}Et voici la réponse du validateur, telle quelle (elle est en anglais) :
Not valid: 4 errors. Nothing was saved.
- steps[0].appId: a package name and a bundle id differ; give {"android":"com.example.app","ios":"com.example.App"}
- steps[1].selectors[0].using: "accessibilityId" is Android-only; put this step in an "if" with
condition {"check":"platformIs","expected":"<android|ios>"}, or make the flow single-platform
- steps[3].value: this types into a password field; a literal value is not stored.
Use {{secret.KEY}} and save the secret under Variables
- steps[4].action: "key" is Android-only; put this step in an "if" with
condition {"check":"platformIs","expected":"<android|ios>"}, or make the flow single-platformChaque erreur nomme l'étape, le champ et la correction. Un champ qu'une action n'utilise pas est
lui aussi une erreur, pas une donnée ignorée en silence : une faute comme "selector" au lieu de
"selectors" est détectée avant la première exécution. C'est cette précision qui rend le format
utilisable par des agents : la réponse est assez précise pour être corrigée sans qu'un humain la
lise.
Le téléverser : dans l'application, ou par votre IA
Dans l'application. Ouvrez Test Manager → Tests et cliquez sur Import JSON. Déposez le
fichier .json (1 Mo au maximum), choisissez-le, ou collez-le dans la zone de texte. Le flux est
vérifié avant tout enregistrement. Les erreurs s'affichent sous la forme step N · field · message,
et Import reste désactivé tant qu'elles ne sont pas corrigées. Les avertissements, comme un
{{secret.KEY}} pas encore enregistré, ne bloquent rien. Cliquez sur Import : le flux devient
un nouveau test avec son propre identifiant T###.
Par votre assistant IA. Connectez Claude, Cursor ou un autre client MCP à RobotActions et donnez-lui le fichier :
Valide
sign-in-and-add-to-cart.jsonavec flow_validate. S'il est valide, enregistre-le tel quel avec flow_import.
flow_validate fait le même essai à blanc que la boîte de dialogue, et flow_import le même
enregistrement. Dans les deux cas, le fichier passe sans modification.
Comme un flux est un simple fichier, tout ce que vous faites déjà avec des fichiers fonctionne ici. Rangez les flux à côté du code de l'application, relisez-les dans les pull requests et téléversez la version relue.
Le faire écrire par un agent
Un agent utilise les mêmes outils, plus un : flow_validate (un essai à blanc), flow_import
(enregistrer) et flow_replay_start (l'exécuter sur un appareil). Une demande aussi
simple que celle-ci suffit :
Ouvre l'application de boutique sur un téléphone Android et reconstitue le parcours connexion + ajout au panier. Écris-le comme flux
mobile, valide-le, enregistre-le, puis rejoue-le sur un téléphone Android et sur un iPhone et dis-moi quelles étapes ont échoué.
Un agent qui a l'appareil sous les yeux lit le vrai arbre des éléments avant d'écrire un
sélecteur : il écrit id: email_input parce que cet identifiant existe, pas parce qu'il semble
plausible. Si flow_validate renvoie une erreur, celle-ci indique à l'agent quel champ modifier.
L'agent n'a jamais à deviner ce que voulait le validateur.
Le corriger quand l'application change
C'est là qu'un fichier l'emporte sur un enregistrement. Quand une version renomme « Sign in » en « Log in » :
- Cliquez sur l'icône Export JSON du test. Vous obtenez
<name>.json: le flux, avec sonrecordingIdet saversion. - Changez un mot.
- Cliquez sur l'icône Update from JSON du test et chargez le fichier. La boîte de dialogue
indique ce qui va changer, par exemple
replaces 11 steps with 11 · v3 → v4. Cliquez sur Replace steps.
Un assistant IA fait la même chose avec flow_export, une modification, puis flow_update avec ce
recordingId et expectedVersion.
- { "action": "tap", "selectors": [{ "using": "text", "value": "Sign in" }] },
+ { "action": "tap", "selectors": [{ "using": "text", "value": "Log in" }] },Le test garde son identifiant T###, son lien vers le cas de test et son historique de
relectures. Si quelqu'un d'autre a enregistré le test après votre export, vous êtes prévenu avant
tout écrasement : deux personnes ne peuvent pas annuler le travail l'une de l'autre sans le savoir.
Chaque mise à jour est versionnée (les dix dernières sont conservées), et Version history →
Revert dans l'éditeur de flux en annule une.
Si les deux libellés vont coexister un temps, pendant un déploiement progressif par exemple, ne choisissez pas. Acceptez les deux :
{ "using": "text", "value": "(Sign|Log) in", "regex": true }Quand une étape échoue à la relecture, le message est aussi précis que celui du validateur. Il indique que l'élément est introuvable, ou que plusieurs correspondent, et quel sélecteur a été essayé. La plupart des corrections portent sur un sélecteur, donc sur une ligne.
Enregistrement et JSON : le même flux
Vous n'avez pas à choisir entre enregistrer et écrire. Un flux enregistré sur un appareil réel s'exporte dans ce JSON : vous pouvez enregistrer un premier jet à la main, puis peaufiner les sélecteurs dans un éditeur.
Une limite mérite d'être connue. Les étapes qui n'ont de sens que comme positions à l'écran, comme
un balayage libre ou un toucher à une coordonnée brute, ne peuvent pas s'écrire en sélecteurs.
Elles sont signalées comme omises à l'export d'un flux enregistré, et flow_update refuse de
réécrire ce flux depuis le JSON plutôt que de les perdre. Vous gardez l'enregistrement, et rien ne
disparaît dans votre dos.
La même idée pour un navigateur
Les flux web utilisent la même enveloppe avec "platform": "web" et un jeu d'actions adapté au
navigateur (navigate, tap, type avec un sélecteur, hover, key, back, dialog, des
assertions de page comme urlContains). Ils acceptent aussi les variables d'environnement
({{BASE_URL}}). Un même flux peut donc tourner sur la préproduction et la production.
{
"name": "Browse skills",
"platform": "web",
"steps": [
{ "action": "navigate", "url": "{{BASE_URL}}" },
{ "action": "tap", "selectors": [{ "using": "role", "value": "link=Browse the skills" }] },
{ "action": "assert", "assert": { "kind": "urlContains", "expected": "/skills" } }
]
}Où se situe RobotActions
Le format est le nôtre, mais les habitudes qui le sous-tendent valent dans n'importe quel outil : désigner les éléments plutôt que des positions, garder les secrets hors des fichiers de test, faire de chaque différence de plateforme un bloc explicite, et échouer clairement sur une correspondance ambiguë. Sur RobotActions, un flux s'exécute sur de vrais téléphones Android et iPhone dans le cloud, seul ou au sein d'une suite, et renvoie à son cas dans TestRail, Jira ou Azure DevOps.
Écrivez votre premier flux dans un éditeur et importez-le, ou demandez à votre agent de l'écrire, puis rejouez-le sur les deux téléphones. Essayez gratuitement · Sans carte de crédit →
À lire aussi
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.