Tester automatiquement une application Flutter

Maxime Pontoire
12 min readOct 26, 2022

--

This article is also available in English.

SNCF Connect developers are loving Flutter

Introduction

De plus en plus d’applications sont développées en utilisant le framework Flutter, afin d’être disponibles à la fois sur iOS et Android (avec une grosse partie du code mutualisé). Depuis 2 ans, la communauté a rendu ce framework stable et a fourni de nombreux outils pour tester le code et les applications générées. Durant la même période, nous avons commencé à construire SNCF Connect, une application aujourd’hui utilisée par des millions de Français. Notre ambition était d’aller vers le “delivery continu” grâce à des solutions à l’état de l’art et des tests automatisés.

Si vous avez déjà développé une application mobile, en utilisant les SDK natifs, vous savez à quel point il peut être fastidieux de les tester, à chaque nouvelle fonctionnalité ajoutée. Il existe bien les tests unitaires pour valider le code et les règles métier, les tests “golden” pour détecter les changements d’interface, mais tous ces tests sont statiques et ne permettent pas de valider une feature ou un parcours utilisateur dans l’application.

La pyramide de tests (expliquée pour Flutter dans cet article d’Alexandre Poichet avec Flutter), prévoit la mise en place de tests d’ “Intégration” et d’ “UI”. Mais si vous avez déjà eu à développer ce type de tests sur des applications natives, vous connaissez la difficulté à les maintenir, ainsi que le manque de fiabilité des résultats (notamment à cause des frameworks de test natifs).

Dans cet article, je vais vous montrer comment nous avons développé, maintenu et utilisé les tests automatisés dans le processus de delivery continu de l’application SNCF Connect. SNCF Connect est un nouveau service digital, héritier de OUI.sncf, qui permet de planifier tous les trajets de courte et longue distance du 1er au dernier kilomètre.

Un peu d’histoire

Avant SNCF Connect existaient OUI.sncf et L’Assistant, deux applications qui proposaient de réserver des trains, de recherche un itinéraire porte-à-porte, de l’info-traffic… et ce partout en Europe. Ces applications étaient développées pour l’AppStore et le Google Play Store, en utilisant les frameworks natifs propres à chaque OS.

Les tests UI étaient également développés en utilisant les frameworks natifs de tests (XCTest pour iOS, Espresso pour Android). Ceci impliquait de devoir coder, tester et maintenir les tests sur chacune des plateformes. D’autres frameworks étaient disponibles, mais ils ne permettaient pas de tester des applications iOS.

Un autre point négatif concernant ces frameworks : leur manque de stabilité. Nous ne pouvions être sûrs d’avoir le même résultat, à l’issue de chaque exécution (et même si nous utilisions des bouchons/mocks des APIs). Pourquoi ? Plus votre application a un parcours complexe, plus vous rencontrez des animations, des appels réseaux… et les frameworks natifs gèrent mal de grosses applications.

Sur OUI.sncf, nous avions désactivé les animations et “mocké” les appels, mais nous avions toujours beaucoup d’instabilités liées à XCTest, lors de nos exécutions. Testeurs ou les jobs d’Intégration Continue devaient relancer le test, pour être sûr de l’échec et de sa raison (et éviter les faux négatifs dus au Framework).

Choisir la bonne pilule

Pour SNCF Connect, nous avons étudié différents frameworks pour tester notre application Flutter. Notre évaluation s’est basée sur les critères suivants :

  • Stabilité : les résultats doivent être fiables (si le test échoue, la raison de l’échec doit être la même à chaque exécution)
  • Intégration : les tests doivent pouvoir s’exécuter indistinctement sur une machine local ou sur la plateforme d’Intégration Continue
  • Rapport : les rapports de tests doivent être compréhensibles aussi bien par un développeur qu’un testeur
  • Intuitif : les tests doivent être simples à développer et maintenir

Bonus : puisque nous développons à la fois une application pour iOS et Android, avoir également une base unique de code pour les tests serait un plus.

Grâce à notre expérience avec les frameworks natifs, nous savions que les frameworks natifs ne permettaient pas de satisfaire à notre exigence de stabilité.

Nous avons étudié plusieurs solutions et avons retenu Appium, mais pas leur SDK pour Flutter. En effet, au démarrage des développements de SNCF Connect, le driver Flutter Appium était encore en beta et beaucoup de fonctionnalités manquaient.

En parallèle, nous avons travaillé sur la manière dont nous souhaitions écrire et organiser nos tests. Sur OUI.sncf, nous avions éprouvé la combinaison Gherkin + Cucumber pour décrire nos tests backend (voir ici pour Gherkin/Cucumber). Ce language permet en effet d’améliorer la lisibilité des tests, les rend plus simples à comprendre pour un développeur ou un testeur. L’avantage d’un test Gherkin est qu’il décrit une fonctionnalité étape par étape, ce qui permet de rédiger au passage les spécifications fonctionnelles de l’application et du site Web. Ainsi pour SNCF Connect, nous utiliserions Gherkin pour écrire les étapes de nos tests, chaque test sera exécuté pour qualifier le Web, les applications mobiles et le BFF (Back-For-Front), si cela est pertinent.

Ainsi notre décision était claire : Appium + Cucumber (pour lire les Gherkins), en Kotlin. Kotlin s’imposait puisque notre backend (plus précisément notre BFF) est écrit en Kotlin. De plus, ce langage était déjà maîtrisé par les développeurs, et proche de Swift connu des développeurs iOS.

Assembler les Lego

Si vous n’avez jamais implémenté un test rédigé en Gherkin, Cucumber simplifie grandement l’intégration. En effet, Cucumber lit chaque de étape (une étape étant l’interaction que réalise l’utilisateur avec l’interface, quelle qu’elle soit) d’un fichier .feature.

Exemple de fichier .feature décrivant un Gherkin

Ensuite, Cucumber essaie. de trouver la “glue” correspondante et exécute le code associé. Cette glue peut être la phrase exacte, ou une expression régulière :

class WhenSteps {

@Lorsque("(?i)^l'utilisateur accède à l'info trafic de la zone \"(.+)\"$")
fun selectZone(zoneName: String) {
TrafficInfoScreen.selectZone(zoneName)
}
}

Dans cette exemple, la glue repose sur une regex, qui vient rechercher les étapes qui débutent par la chaine, et qui utilise le “(.+)” pour extraire le paramètre, utilisé par la méthode associée. Si on regarde en détail cette méthode, on comprend qu’il s’agit d’un appel à une action d’un écran.

Pourquoi ce modèle ? Parce que nous avons mis en place le Pattern Page Object, pour concevoir l’architecture de notre code de test. Nous avions déjà utilisé ce pattern pour OUI.sncf, et il est toujours parfaitement adapté pour mettre en place les tests au format Gherkin avec Cucumber.

Il est d’autant plus puissant qu’il permet de décrire chaque écran (ou partie d’écran) au sein d’une classe, avec une méthode pour chaque action/interaction réalisable au sein du dit écran. Chaque action a ensuite la charge d’orienter le code de test vers le prochain écran du parcours.

Dans notre module de tests, nous avons mis en place une classe abstraite avec toutes les méthodes nécessaires au pattern :

abstract class PageScreen {

private val logger = KotlinLogging.logger {}
private val modalErrorByElement = ByLocalizedTextAndClass("genericErrorTitle", Locators.viewClassName)
private val backButtonByElement =
ByLocalizedTextAndClass("accessibility_back_button", Locators.buttonClassName)

abstract fun isScreenDisplayed(): Boolean

fun activity(msg: String) {
logger.info {
this::class.toString().replace(packageName, "").replace("class", "").trim() + " => " + msg
}
}

fun logError(msg: String) {
logger.error {
this::class.toString().replace(packageName, "").replace("class", "").trim() + " => " + msg
}
}

fun checkIfErrorIsDisplayed(): PageScreen {
if (doesScreenContainElement(modalErrorByElement, 15)) {
logError("Une erreur de type inconnu s'est produite")
fail("Une erreur de type inconnu s'est produite")
}
return this
}

fun goBackToPreviousScreen(): PageScreen {
activity("Retour vers l'écran précédent")
Getters.getElementBy(backButtonByElement).click()
return this
}
}

Comme on peut le voir, la classe fournit des fonctions, utiles pour naviguer au sein de l’application ou logger les commandes Appium, envoyées par le driver à l’émulateur/simulateur/mobile (ce qui couvre notre exigence de Rapport).

Arrêtons-nous un instant sur une méthode en particulier :

abstract fun isScreenDisplayed(): Boolean

Implémentée au sein de chaque classe, héritant de PageScreen, elle est nécessaire pour s’assurer que l’application affiche bien le bon écran, avant de permettre au test d’exécuter l’action suivante.

Le pattern Page Object et Gherkin/Cucumber simplifie également le développement et maintenance de la base de code : coder une étape la rend disponible pour tous les tests Gherkin qui l’utilisent. Faire évoluer le code (suite à un changement dans l’application) le met également à jour pour tous les tests. Si un changement important doit être fait dans le parcours de l’utilisateur (ajout/suppression d’un écran par exemple), il suffit de mettre à jour la ou les étapes vers ce nouvel écran, coder la nouvelle interaction et brancher à l’appel précédent si nécessaire. Cela couvre notre exigence d’un outil Intuitif.

Côté code ?

Comme indiqué précédemment, nous avons retenu Appium, pour interagir avec l’application. Appium fournit des méthodes pour sélectionner, faire défiler ou cliquer sur l’écran, comme le ferait un utilisateur.

Nous avons cependant développé nos propres méthodes, en sur-couche d’Appium :

/**
* Return an element of the screen, using a By selector.
*
@param selector way to select the object.
*
@param secondsToWait timeout of max number of seconds to wait.
*
*
@return MobileElement matching selector.
*/
fun getElementBy(selector: By, secondsToWait: Long = SECONDS_TO_WAIT, maxNumberOfSwipes: Int = 5): MobileElement {
logger.debug { "$selector, timeout : $secondsToWait" }
scrollTo(selector, maxNumberOfSwipes = maxNumberOfSwipes)
waitForVisibility(selector, secondsToWait)
return driver.findElement(selector) ?: error("Elément $selector non trouvé !")
}

Cette méthode est utilisée pour rechercher un élément dans le DOM de l’application. Si aucun élément n’est trouvé, alors une erreur (incluant le sélecteur recherché) est loggée dans le rapport de test. Cela peut être un mauvais sélecteur codé, ou une véritable erreur applicative.

Voici comment nous utilisons cette méthode :

private val stationsBoardBigLinesBnByElement = ByLocalizedTextAndClass(
"stationBoards.big.lines.label",
viewClassName
fun switchToBigLinesTab() {
activity("Ouverture de l'onglet Grandes lignes")
getElementBy(stationsBoardBigLinesBnByElement).click()
}

Tout d’abord, nous loggons l’action en cours, puis recherchons un élément et cliquons dessus.

Pour faire cette recherche :

private val stationsBoardBigLinesBnByElement = ByLocalizedTextAndClass(
"stationBoards.big.lines.label",
viewClassName

nous avons développé notre propre méthode :

class ByLocalizedTextAndClass(
accessibilityId: String,
clazz: ByClassName,
caseSensitive: Boolean = false,
withoutAccent: Boolean = false,
input: Map<String, String> = emptyMap()
) : ByTextAndClass(
accessibilityId.localize(input),
clazz,
caseSensitive = caseSensitive,
withoutAccent = withoutAccent
)

Elle utilise 2 paramètres :

  • l’accessibilityId (la chaine de caractères ou l’ID que nous souhaitons dans l’écran) : comme les IDs “réels” des objets changent dans l’application, ceci est le seul objet du DOM que nous maîtrisons.
  • className: le type d’objet que nous cherchons. Ceci va permettre d’améliorer la vitesse de recherche dans l’arbre, en filtrant d’abord les objets par ce type, puis en cherchant le premier dont l’accessibilityId correspond

Le className fourni est un alias vers le vrai type d’objet, dépendant du mobile sur lequel s’exécute le test :

val viewClassName: ByClassName = when (platform) {
ANDROID -> ByClassName("android.view.View")
IOS -> ByClassName("XCUIElementTypeStaticText")
}

Avec ces alias, il suffit de sélectionner le bon className pour trouver l’objet, sans tenir compte de la plateforme. De cette manière, nous développons directement le test pour iOS et Android, avec un seul code. Ceci couvre notre exigence Bonus.

Concernant les autres paramètres :

  • caseSensitive : la recherche doit elle respecter les majuscules/minuscules
  • withoutAccent : la recherche doit elle tenir compte des accents
  • input : pour remplacer des chaînes contenant des paramètres

Vous avez sans doute remarqué le terme Localized dans le nom de la méthode. Il est dû à l’utilisation des libellés (“wordings”) localisés dans nos tests. En effet, puisque la recherche d’un objet repose en partie sur son texte, via l’accessibilityId, il est plus pertinent de se baser sur une clé de wording fixe. Si un wording vient à changer dans l’application, aucune évolution de code n’est nécessaire (le fichier des libellés étant mutualisé entre le code applicatif et le code de test). De plus, le test développé peut également être utilisé pour tester l’application dans n’importe quelle langue supportée.

Exécuter les tests

Une fois le test et ses étapes développés, nous souhaitons l’exécuter à la fois pour vérifier qu‘il se déroule correctement, mais aussi pour vérifier qu’il couvre bien les exigences du Gherkin et la fonctionnalité concernée.

Utilisant Appium via Kotlin sur SNCF Connect, nous avons la possibilité de lancer un test sous forme de tâche gradle. Dans le module de tests, nous avons défini des configurations d’exécution dédiées aux IDEs de nos développeurs (IntelliJ, VSCode…) pour lancer un test ou un lot complet. Comment ? En utilisant des annotations :

@mobile @sdk @web
@IVTS-XXXXX @test_module

Nous avons défini ces annotations pour décrire sur quelle plateforme le test peut s’exécuter, puisqu’un même Gherkin peut être utilisé pour un test, quel que soit l’OS.

Même si nous classons nos fichiers feature dans des répertoires dédiés, chacun correspondant à un module applicatif, nous avons mis en place une annotation au format @IVTS-XXXX, permettant d’identifier le test : IVTS étant le nom de code initial du projet, suivi du numéro de ticket Jira, dans lequel se trouve le Gherkin et les détails du test. Nous avons également mis en place l’annotation @module_name pour décrire le module testable via ce gherkin.

De cette manière, quand nous souhaitons tester une fonctionnalité, nous utilisons ces annotations pour lancer soit un test spécifique, soit tous les tests des modules impactés par la fonctionnalité.

L’exécution sur un poste de développement peut se faire de différentes manières, soit via les configuration d’IDE, soit en exécutant un script shell et la tâche gradle.

Ce script est également le même que celui exécuté sur notre plateforme d’Intégration Continue (ce qui couvre notre exigence d’Intégration).

Tests automatisés et Intégration Continue

Nous n’aborderons ici en détail pas comment nous construisons/déployons le site Web, l’application, ou le BFF via notre Intégration Continue.

SNCF Connect repose sur un “mono-repo” : tout le code de l’application, du site Web, du BFF, des Lambda (serverless), des tests, des autres assets et même l’Infra-as-Code se trouvent dans le dépôt Git, partagé par tous les développeurs.

À chaque changement fait sur ce répertoire, une Merge Request est ouverte et nous devons détecter les erreurs/régressions potentielles au plus tôt.

Pour cela, nous avons mis en place plusieurs jobs s’exécutant dans un même “pipeline de merge” : tests unitaires, tests golden, tests BFF et bien sûr les tests automatisés de parcours sur l’application et le site. Nous pouvons réduire le nombre de tests à exécuter, si la Merge Request n’impacte qu‘une plateforme spécifique (par ex. si le changement porte sur l’application, nous réduisons le volume des tests automatisés exécutés pour tester le site Web).

Jobs des tests automatisés

Comme on le voit sur cette capture d’un pipeline d’Intégration Continue, plusieurs jobs s’exécutent en parallèle, certains étant bloquant d’autres non.

Remarque : ces tests couvrent plusieurs couches front-end et BFF, c’est pourquoi nous les appelons tests de bout-en-bout (« end-2-end » ou « e2e ») même si d’autres APIs back-office sont en revanche des mocks.

Durant l’étape de préparation :

  • nous compilons le code des tests pour chaque plateforme
  • nous déployons une version de l’application sur SauceLabs, notre fournisseur de devices physiques (N.B. nous pouvons changer de fournisseur assez simplement, tant qu’il prend en charge le driver Appium).

Durant l’étape d’exécution des tests :

  • nous testons le BFF, pour nous assurer que les APIs sont toujours valides
  • nous testons l’application/site Web dans un environnement mocké. A l’heure actuelle, nous exécutons systématiquement un test, qui a une forte valeur métier

Dans les prochaines semaines, nous ajouterons d’autres tests dans des jobs en Intégration Continue, avec nos cas d’utilisation les plus importants, afin de continuer à détecter les régressions éventuelles, au plus tôt.

Bilan (et prochaines étapes)

Depuis les débuts de nos développements pour SNCF Connect, nous avons pu faire de nombreux choix quant à nos tests auto, et ensuite éprouver ces choix pendant des mois.

Concernant Appium, le moins que l’on puisse dire, c’est que ce framework est stable : si un test échoue, il le fera systématiquement à la même étape pour la même raison (à chaque exécution dans le même environnement mocké), qu’il s’exécute sur la plateforme d’Intégration Continue ou une machine locale (satisfaisant ainsi notre exigences de Stabilité). Cela garantit la qualité des résultats de nos tests, et permet de reproduire une erreur sur une machine de développement et pour corriger rapidement un test si besoin. La stabilité d’Appium, ainsi que sa capacité à cibler iOS et Android avec le même code, a permis d’améliorer la fiabilité des résultats pour chaque plateforme et de mettre en place des rapports correspondant à nos besoin.

Ces tests auto nous ont permis de détecter des régressions dans l’application et le site Web via ses tests automatisés dédiés avant le déploiement en production, ce qui reste évidemment notre priorité, ainsi que des faiblesses dans l’architecture de notre plateforme d’Intégration Continue.

Même s’il nous a fallu des mois pour mettre en place une bibliothèque de tests avec un volume d’étapes et de parcours suffisant, développer de nouveaux tests est maintenant très rapide. Ajouter de nouvelles étapes est d’autant plus simple que notre surcouche d’Appium est dorénavant éprouvée. L’utilisation du pattern Page Object contribue également à la fluidité des développements et la maintenance des tests (par la possibilité de faire évoluer une étape, et de la rendre disponible pour toutes les autres tests).

Enfin, l’arrivée et la prise en main du projet par des nouveaux développeurs/testeurs est simplifiée, chaque test étant écrit en langage naturel (et en français).

Et la suite ?

Actuellement, nous en sommes à publier chaque semaine une nouvelle version majeure (incluant des fonctionnalités, pas de seulement des correctifs) de l’application, du site Web et du BFF en production. Cela nous permet d’apporter plus de valeur plus fréquemment à nos utilisateurs, avec un « time-to-market » bien inférieur à celui que nous avions sur OUI.sncf. Ceci nécessite toutefois beaucoup d’attention et une charge importante, de tests de non-régression, pour les testeurs fonctionnels. Notre référentiel Jira étant connecté à XRay, nous travaillons à lancer des exécutions de tests XRay, directement depuis la plateforme d’Intégration Continue, pour tester des modules complets de l’application et du site Web via leurs annotations. Je reviendrai dans d’autres articles sur comment nous avons automatisé le processus complet de tests de Non Régression de l’application SNCF Connect.

Et vous ? Avez-vous déjà rencontré ces problèmes, ces enjeux ? Avez-vous des solutions à partager ? N’hésitez pas à nous contacter ou nous rejoindre pour travailler sur le sujet, nous cherchons des testeurs/automaticiens avec des offres d’emploi ouvertes en ce moment 😉

--

--