03 février 2014

iOS et tests automatisés

En faisant un peu de programmation iOS, je me suis posé la question d’automatiser mes tests. Apple fournit une panoplie d’outils tels que UIAutomation, qui permet d’écrire des tests automatisés en Javascript et inclus dans le projet, mais vu que j’ai déjà quelques sanity checks codés en Java (typiquement, l’extraction de points de contrôles des fichiers statiques xib de la configuration de l’UI, et leur vérification), bah, j’aimerais bien :
  1. pouvoir coder mes tests en Java,
  2. qu’ils se lancent à partir de Maven
  3. et que les résultats soient lisibles par Jenkins.
Je débute et j’explore.

Appium


J’apprends qu’on peut utiliser un WebDriver (oui, le même qu’avec Selenium) qui va taper sur un outil Appium (open source), qui va lui-même interagir avec Instruments (Apple), qui va déclencher l’application iOS à tester.

Note : j’ai découvert Appium grâce à cette vidéo : GTAC 2013: Appium: Automation for Mobile Apps — 43 min.

On a deux chaînes intéressantes :
  • Dev : Eclipse → JUnit → Appium (en local) → iOS Simulator
  • CI : Jenkins → Maven → JUnit → Appium (par HTTP) → iOS Simulator

Cela dit, le WebDriver ne permet pas de récupérer toutes les informations de l’application iOS, et notamment la totalité des caractéristiques des UIViews. Il va donc falloir instrumentaliser nous-mêmes l’application pour en savoir davantage.

J’ai par exemple quatre cas de tests qui font intervenir des chaînes différentes :
  1. Vérification de layout
    — exemple : tels boutons doivent être alignés et avoir les mêmes dimensions
     
  2. Vérification de la mise à jour du texte d’un UILabel
    — exemple : après avoir cliqué sur tel bouton, tel label doit afficher tel texte
      
  3. Vérification de la mise à jour de la couleur d’un UILabel
    — exemple : après avoir cliqué sur tel bouton, tel texte doit être affiché de telle couleur

A. Vérification de layout


— exemple : tels boutons doivent être alignés et avoir les mêmes dimensions

C’est la toute première chose à laquelle j’ai dû penser après avoir vu que,  pour définir le design des écrans, Interface Builder allait être utilisé plus ou moins systématiquement. Ce machin est tellement instable que même en consultant ses propres travaux précédents et en faisant « Pomme-S » (réflexe d’avant le cloud), on n’est pas sûr que des modifications n’aient pas eu lieu. En sus, on a beau définir toutes les contraintes imaginables, elles sautent à la moindre occasion et/ou au final ne s’appliquent pas comme prévu.

La seule vérification fiable est au runtime, d’où l’écriture de tests qui pilotent iOS Simulator.

Voyons comment on s’y prend.

Primo, on va donner dans Xcode (en fait, dans le xib) un identifiant au bouton auquel on va vouloir accéder en test. À savoir, le xib permet entre autres de définir pour une UIView les identifiants suivants :
  • userLabel : un texte arbitaire pour aider à retrouver ses petits dans Interface Builder → ne passe pas l’étape du build : n’est pas accessible au runtime
  • tag : un numérique (1, 2, 3…), interne → éventuellement utile pendant le coding Objective-C, présent ensuite au runtime, mais pas accessible avec le WebDriver
  • accessibilityLabel : utilisé par VoiceOver notamment → accessible par By.name(xxx)
  • accessibilityIdentifier : interne → accessible par By.id(xxx)

Pour définir un tag, aller dans l’Attributes inspector :


Pour définir un accessibilityLabel, aller dans l’Identity inspector : 


Rappel : c’est cet accessibilityLabel que lira à haute voix VoiceOver s’il est activé.

Pour définir un accessibilityIdentifier, aller aussi dans l’Identity inspector…


et ajouter à la main un champ String « accessibilityIdentifier » :


On accède ensuite au bouton depuis le code Java du test de la façon suivante :
final WebElement button = driver.findElement(By.id("L20"));
// or: button = driver.findElement(By.name("L20"));
// or: button = driver.findElement(By.name("Bouton de validation"));
et à ses propriétés comme ceci :
assertEquals(75, button.getLocation().x);
assertEquals(61, button.getLocation().y);
assertEquals(106, button.getDimension().width);
assertEquals(127, button.getDimension().height);
Noter qu’on peut également aller chercher soi-même ces informations dans le driver.getPageSource(), où elles apparaissent comme ceci :
{
 "name":"L20",
 "type":"UIAButton",
 "label":"Bouton de validation",
 "value":"Bouton de validation",
 "rect":{"origin":{"x":75,"y":61},"size":{"width":106,"height":127}},
 "dom":null,"enabled":true,"valid":true,"visible":true,
 "children":[],
 "hint":"Blah Blah Blah"
}
La présence des deux attributs accessibilityIdentifier et accessibilityLabel, ou d’un seul des deux, a une influence sur les méthodes qu’on va utiliser et les attributs renvoyés :

id, label="toto" id="123", label="toto" id="123", label
findElement(By.id(?)) By.id("toto") By.id("123") By.id("123")
findElement(By.name(?)) By.name("toto") By.name("123") By.name("123")
getAttribute("name") "toto" "123" "123"
getAttribute("value") "toto" "toto" "" (vide)


Bon, je préfère évidemment l’accessibilityIdentifier :
  • il peut être unique à travers les écrans de l’appli, alors que l’accessibilityLabel sera probablement répété dans des pages connexes (ben oui, pour des histoires d’accessibilité, on répète forcément les mêmes accessibilityLabels pour les mêmes boutons, afin de ne pas perdre l’utilisateur),
  • il est fixe dans le temps, alors que l’accessibilityLabel sera appelé à évoluer en fonction des retours utilisateurs,
  • il n’a pas à être traduit.

B. Vérification de la mise à jour du texte d’un UILabel


— exemple : après avoir cliqué sur tel bouton, tel label doit afficher tel texte

Bon, on se dit que ça devrait être simple :

final WebElement button = driver.findElement(By.id("truc"));
final WebElement label = driver.findElement(By.id("machin"));

assertEquals("Avant", label.getText()); // Alors, ça marche ?
button.click();
assertEquals("Après", label.getText()); // Alors, ç
a marche ?
Eh ben non, ça ne fonctionne pas, getText() ne renvoie en fait rien, et ce simplement parce qu’il y a un bug dans l’UIAutomation d’Apple : Appium does not return correct value from UIAStaticText

Dommage, l’API de WebDriver avait pourtant tout prévu : la méthode getText() existe pour WebElement, ça sentait à peu près bon.

Bien, alors quel est le souci, en fait ? C’est que lorsqu’Appium traduit nos appels Java en appels Javascript pour UIAutomation, celui-ci a un bug qui ne récupère pas les bonnes valeurs auprès de l’environnement d’exécution de notre application en Objective-C.

Du coup, on va aller invoquer Objective-C directement depuis Java, et cela nous amène au cas suivant.

C.  Vérification de la mise à jour de la couleur d’un UILabel


— exemple : après avoir cliqué sur tel bouton, tel texte doit être affiché de telle couleur

Pour ce cas, c’est clair : l’interface WebDriver ne prévoit absolument pas de récupérer la couleur d’un élément. Il y a bien une méthode getAttribute(xxx) dans WebElement, mais, euh, un coup d’œil à la pageSource (voir plus haut) confirme que c’est assez pauvre et qu’en l’occurrence la couleur n’en fait pas partie.

Il va falloir instrumentaliser d’une façon ou d’une autre notre propre application iOS en Objective-C, d’une façon qui rende ces informations disponibles au code Java de nos tests.

Alors, en ce qui me concerne, j’ai ajouté ceci :
  1. Un bouton « DumpUIViews » à mon IHM, actif uniquement en phase de debug (et donc désactivé à tous les niveaux en phase de release).
     
  2. Une méthode « dumpUIViews » qui se déclenche quand on clique sur ce bouton, et qui écrit dans un fichier toutes les caractéristiques que j’ai trouvées intéressantes, et ce pour toutes les UIViews : tag, accessibilityIdentifier, backgroundColor… en plus de caractéristiques des sous-classes : UILabel.text, [UIButton titleColorForState:xxx], etc.
     
  3. Côté instrumentalisation Java, de quoi lire ce fichier, le transformer en XML (et lire à l’intérieur grâce à avc-binding-dom, évidemment juste un détail).
     



Alors c’est un peu long et un peu riche, sans doute, de dumper toute la hiérarchie d’objets UIView après chaque interaction et notamment après chaque clic, mais, euh… ça fonctionne.

Il faut voir ça comme une capture d’écran — que permet le WebDriver relié à Appium, d’ailleurs.

Considérations diverses


Sur les tags : rien n’impose qu’un tag d’objet UIView soit unique dans toute l’application, et d’ailleurs parfois il vaut mieux avoir des objets différents avec le même tag. Donc, si c’est une contrainte de développement, autant avoir un garde-fou d’une sorte ou d’une autre : test JUnit…

Sur les accessibilityIdentifiers : même remarque, sauf que là, on s’attendrait quand même à ce que l’unicité soit obligatoire.

Sur le fait de réaliser des captures de layouts en plus de captures d’écrans : cela permet une analyse a posteriori de certains éléments plus fins. On peut donc voir le processus de test en deux phases :
  • Une première phase qui remplit les formulaires, clique sur les boutons, et opère toutes les captures de layouts et d’écrans possible ;
  • Une deuxième phase qui analyse ces données à froid : vérification des contraintes de tailles, de couleurs, d’alignements, validation des libellés…

Et ce peut être le même code qui, passé deux fois, s’occupe de chacune des phases.