En-tête-canalplus

Écrire des tests comme Shakespeare

Michael Kutz
Blog > L'automatisation des tests
Écrire des tests comme Shakespeare

Gardez vos tests automatisés lisibles avec le pattern Screenplay

Quel est le problème ? Pourquoi devrais-je lire ceci ?

Les tests automatisés complexes - en particulier les tests d'interface utilisateur - ont tendance à manquer d'une certaine qualité de code. C'est du moins mon expérience personnelle au cours des dix dernières années.

Une grande partie du code d'automatisation que j'ai lu était très détaillée, me laissant deviner l'intention générale. D'autres exemples étaient très abstraits, ce qui m'obligeait à creuser dans le code pour trouver la cause profonde d'un échec.

Dans l'ensemble, il était assez difficile de modifier, de remanier et parfois même de comprendre le code, car de nombreux éléments étaient soit implicitement connectés, soit dupliqués.

Je ne parle pas du code que d'autres personnes ont écrit. Je parle ici de mon propre code. Pendant longtemps, j'ai lutté pour trouver la bonne abstraction pour mon code de test.


Les PageObjects ne sont pas nécessairement une solution ici.

Premièrement, ils ne s'appliquent qu'aux applications de type page. Il serait maladroit (pas impossible) de tester une API ou une application CLI en utilisant ce modèle. De plus, les applications modernes à page unique nécessitent une certaine créativité pour appliquer les PageObjects.

Deuxièmement, l'abstraction est typiquement limitée par les limites des pages. Ce n'est pas forcément la bonne. Parfois, une application nous fait suivre un flux de travail de plusieurs pages. Parfois, une seule page contient plusieurs sous-applications prenant en charge des flux de travail très différents.


De nombreux cadres BDD nous permettent d'écrire des tests à n'importe quel niveau d'abstraction, mais le code sous-jacent peut toujours manquer d'organisation et de déboguabilité.

UserObjects

Pendant un certain temps, j'ai donc expérimenté des outils, des cadres et des niveaux d'abstraction. Une chose que j'ai trouvée très utile est de s'en tenir au langage des utilisateurs. Automatiser les flux de travail d'une manière compréhensible pour les Product Owners- au moins au niveau supérieur. J'ai appris que cela rendait le code de test très stable.

Pendant un certain temps, ma propre approche a consisté à créer un "UserObject", une représentation d'un utilisateur, plutôt qu'une représentation d'une page. Ce UserObject fournissait des méthodes permettant d'effectuer certaines tâches qu'un véritable utilisateur pourrait effectuer. Les outils nécessaires pour effectuer ces tâches - par exemple un Selenium WebDriver ou un objet HttpClient - pouvaient simplement être conservés dans des champs. Certains contextes - tels que les informations d'identification, les adresses de courriel, etc. - pourraient également être des champs.

Dans les méthodes de tâches, j'ai toujours utilisé des PageObjects pour permettre à mes utilisateurs de naviguer dans l'application.

J'avais donc mon code en trois couches logiques :

  • des tests écrits dans un langage agréable et adapté aux cas d'utilisation,
  • les méthodes de l'objet utilisateur étaient dans le langage de l'application
  • les PageObjects dans la langue de l'interface utilisateur/HTML.

Cela m'a permis d'éviter les duplications de code et de rendre les tests assez faciles à lire et à écrire.

Énormes UserObjects

Tout cela fonctionnait très bien, mais le UserObject avait tendance à devenir très volumineux. Tous les cas d'utilisation des applications finissaient par devenir une méthode dans l'objet, ce qui en faisait une classe très longue et il était facile de perdre la trace de ce qui était déjà implémenté.

Un autre problème est la granularité des tâches. Parfois, je peux vouloir faire l'étape A, puis B, puis C, alors j'ai créé une méthode doABC. Mais j'avais ensuite besoin d'une variation de ce flux (par exemple, A → B' → C). Je me suis donc retrouvé avec doA, doB, doC et doB' plus doABC et doAB'C - toutes étant des méthodes du UserObject et donc difficiles à ignorer étant toutes au même niveau de code.

Trouver le bon endroit

Bien que le modèle semble assez logique en théorie, en pratique, il était assez difficile de décider où placer certaines logiques. Par exemple : login pourrait être une méthode sur le LoginPageObject, mais aussi une sur le UserObject.

OK, nous ne devrions pas appeler les PageObjects depuis le test. Mais la méthode searchForItem de l'UserObject doit-elle retourner une liste de WebElements ?

Quand on y pense, chaque fonctionnalité sémantique fournie par une page unique pourrait en fin de compte être décrite comme une méthode de tâche sur l'UserObject.

Analyse des échecs

Un autre problème de cette approche était l'analyse des échecs.

En dehors des régressions réelles, où le système testé est à blâmer, la cause première de l'échec d'un test peut se trouver à n'importe quel niveau :

  • Parfois, l'interface utilisateur subit de légères modifications : un bouton a été renommé, un div a un nouvel ID, le nom d'une entrée a changé... Tout cela doit être corrigé et se trouve dans les PageObjects.
  • Dans d'autres cas, les détails de l'application ont changé : il y a un popover de confirmation à la fin de la commande, la barre de recherche a une nouvelle fonction d'auto-complétion... ces éléments doivent être gérés dans le UserObject. Nous devons ajuster les méthodes de tâche pour gérer le comportement supplémentaire.
  • Il est également possible que les cas d'utilisation réels aient été modifiés de manière plus fondamentale : l'enregistrement d'un nouvel utilisateur nécessite la saisie de la date d'anniversaire, la suppression d'un élément ne nécessite plus de confirmation, mais peut être annulée... des choses comme cela devraient très probablement être reflétées au niveau des tests.

La couche supplémentaire - par rapport à PageObjects seulement - pourrait valoir la peine. Mais selon mon expérience, c'est un investissement coûteux.

Code de partage

Finalement, le partage du code et l'ouverture de la base de code aux contributions de différentes équipes sont devenus une exigence pour le site référentiel de test.

Malheureusement, les UserObjects sont très difficiles à étendre. Bien sûr, une équipe à qui il manque des fonctionnalités peut étendre le UserObject et ajouter les fonctionnalités manquantes pour sa propre base de code. Cependant, laisser ces extensions revenir dans la base de code était problématique car les équipes ajoutent souvent beaucoup de cas spéciaux qui n'ont aucune valeur pour les autres équipes, tout en gonflant la base de code et en ajoutant des duplications de code.

D'autre part, le fait de conserver les extensions au niveau local entraîne probablement la mise en œuvre de solutions identiques dans différentes équipes.

Le scénario ?

Après plusieurs itérations de UserObjects, j'ai découvert le modèle Screenplay et j'ai fini par consulter la documentation et l'article original "Page Objects Refactored" pour comprendre en quoi il est différent de UserObjects et comment il résout les problèmes que j'ai rencontrés.

Acteurs avec des capacités

L'entité centrale de Screenplay est l'Acteur. Similaire à un UserObject, un Actor représente un utilisateur de l'application. Contrairement à un UserObject, un Actor ne contient pas directement des outils en tant que champs, mais une liste d'objets Ability, qui enveloppent essentiellement les outils réels.

Par exemple, il peut y avoir une aptitude BrowseTheWeb qui fournit simplement un objet Selenium WebDriver. Il pourrait aussi y avoir une aptitude CallRestApis qui contient un OkHttpClient.

L'Acteur implémente des méthodes pour accéder aux Capacités par leur type.


Acteur alex = nouveau Acteur("Alex")

  .can(new BrowseTheWeb(BrowserType.CHROME)) ;


WebDriver webDriver = alex

  .uses(BrowseTheWeb.class)

  .getWebDriver() ;

Exécution des tâches

Les acteurs n'implémentent pas non plus de méthodes de tâche, mais fournissent une méthode qui prend un objet Task à la place. Une tâche n'implémente qu'une seule méthode "performedAs", qui prend un Acteur comme argument.

L'acteur appelle simplement la méthode performedAs de la tâche donnée en se donnant comme argument. Au sein de la tâche, l'instance de l'acteur peut être utilisée pour obtenir ses capacités à effectuer les interactions avec l'application.


alex.does(new Login("alex", "p4ssw0rd")) ;


classe Login implements Task {

  chaîne de caractère nom d'utilisateur ;

  chaîne de caractère mot de passe ;


  // …

  @Override

  public void performAs(Actor actor) {

        WebDriver webDriver = acteur

          .uses(BrowseTheWeb.class)

          .getWebDriver() ;


        webDriver.get("http://parabank.parasoft.com/") ; 


        webDriver.findElement(By.name("username")) 

            .sendKeys(nom d'utilisateur) ; 

        webDriver.findElement(By.name("password")) 

            .sendKeys(password) ; 

        webDriver.findElement(By.name("login")) 

            .click() ;


  }

}


Au début, j'étais très tenté d'utiliser à nouveau les PageObjects à ce niveau pour structurer davantage mon code. Ceci est très déconseillé par les inventeurs de Screenplay, qui font de la publicité pour mettre les détails techniques - comme la logique d'attente, les secteurs d'éléments - juste là dans la tâche.

L'obéissance à cette règle a finalement résolu deux problèmes :

  1. Il n'y avait aucune question sur l'endroit où placer les interactions. Elles ne pouvaient aller que dans une tâche.
  2. Une interaction était toujours immédiatement liée à l'intention et au contexte. L'analyse des échecs est devenue beaucoup plus facile grâce à cela.

Cette règle simple permet également une optimisation en fonction de l'objectif exact d'une tâche. Par exemple, lors de la recherche d'éléments, il est nécessaire d'attendre que le champ de recherche soit cliquable, mais lors de la connexion, nous pouvons l'ignorer complètement.

Tâches composites

Dans certains cas, les tâches peuvent devenir assez complexes. Prenons l'exemple de la caisse d'un magasin. Elle peut comprendre les tâches suivantes :

  1. Saisir une adresse de livraison,
  2. en fournissant des détails de paiement valides,
  3. en confirmant le passage à la caisse.

S'il existe certainement des tests qui nécessitent des modifications de ce processus - comme l'utilisation d'une adresse de livraison spéciale ou de différents types de paiement - il peut aussi y avoir des tests qui ne souhaitent qu'un seul passage en caisse en une seule étape.

Dans ce cas, nous pouvons créer une tâche de vérification, qui appelle d'autres tâches. Idéalement, ces tâches macro/composites ne devraient pas contenir d'interactions supplémentaires, mais seulement appeler d'autres tâches, afin de ne pas devenir une autre couche d'analyse des défaillances.

Poser des questions

Les Questions sont assez similaires aux Tâches. La seule différence réelle entre les deux concepts est qu'une question renvoie quelque chose, ce qui nous permet de connaître l'état du système testé.


class LoggedInState implements Question<Boolean> {


  @Override

  public Boolean answerAs(Actor actor) {

    final var webDriver = actor.uses(BrowseTheWeb.class)

          .getWebDriver() ;


    return webDriver.findElement(By.linkText("Log Out"))

          .isDisplayed() ;

  }

}


En théorie, une Question peut faire exactement les mêmes choses qu'une Tâche. Cependant, même s'il n'y a aucun moyen de l'imposer, les questions devraient limiter leurs actions au minimum nécessaire pour trouver l'information requise. Lire quelques informations sur la page actuelle est parfait. Naviguer vers une autre page est probablement aussi bien, car les tâches et les questions ne devraient généralement pas s'attendre à être sur une page spécifique. La modification de l'état interne du système testé n'a rien à faire dans une question !

Se souvenir des faits

Une extension du concept original de Scénario que j'ai trouvé extrêmement utile sont les Faits. Les Faits sont des objets de données très simples que les Acteurs conservent dans leur mémoire individuelle et auxquels on peut accéder par des Tâches et des Questions - assez similaires aux Capacités.

Par exemple, les détails de paiement ou les adresses postales sont des données assez complexes qui doivent répondre à certains critères. Placer ces données sous forme de champs dans une tâche ou une question peut s'avérer assez fastidieux.


Acteur ryan = nouvel Acteur("Ryan")

  .apprend(PostalAddress.DEFAULT_NEW_YORK) ;


PostalAddress adresse = ryan

  .remembers(PostalAddress.class) ;


De cette façon, nous pouvons simplement nous fier implicitement à la mémoire d'un acteur, au lieu de les placer tous dans des tâches et des questions.

Conclusion

Après la migration d'une énorme bibliothèque de tests de PageObjects pur, sur UserObjects vers Screenplay, je dirais définitivement que Screenplay est de loin la manière la plus appropriée d'organiser un tel code de test.

Trouver le bon endroit pour la logique est devenu beaucoup plus facile, mais n'est toujours pas trivial. Nous devons encore réfléchir longuement à la fin d'une tâche et au début d'une autre. Mais au moins, les changements sont beaucoup mieux isolés.

Alors que même la version la plus simple de l'objet utilisateur devenait assez énorme, toutes les tâches et questions de la nouvelle version tiennent dans un seul écran.

L'analyse des échecs n'est toujours pas une partie de plaisir, mais comme les tâches et les questions nous fournissent toujours un certain degré de contexte et d'intention, beaucoup d'effets auparavant étranges sont devenus tout à fait compréhensibles. Les Stacktraces nous fournissent maintenant des informations sémantiques et ne sont plus remplis de détails techniques.

Il est également devenu très facile pour les équipes de contribuer à la suite de tests, car elles peuvent désormais fournir leurs propres tâches, qui peuvent - mais ne doivent pas nécessairement - s'appuyer sur d'autres tâches.


Vous voulez essayer Agilitest ?

Découvrez Agilitest en action. Divisez par 5 le temps nécessaire à la sortie d'une nouvelle version.

Automatiser les tests fonctionnels pour des équipes heureuses.  

  • Des tests manuels aux tests automatisés
  • De l'automatisation des tests à l'automatisation intelligente des tests
  • Trouver les bons outils
ebook-scaling-test-automation-agilitest
Michael Kutz

A propos de l'auteur

Michael Kutz

Je travaille dans le développement de logiciels professionnels depuis plus de 10 ans maintenant. J'aime écrire des logiciels fonctionnels et je déteste corriger les bogues. Par conséquent, j'ai développé un fort intérêt pour l'automatisation des tests, la livraison/déploiement continu et les principes agiles. Depuis 2014, je travaille dans le digital en tant qu'ingénieur logiciel et coach interne pour l'assurance qualité et les tests. En tant que tel, mon objectif principal est de soutenir nos équipes de développement dans QA et l'automatisation des tests pour leur permettre d'écrire rapidement des logiciels impressionnants sans bogues.

logo twitter
logo linkedin

Recevez les actualités du monde du test et d'Agilitest dans votre boîte mail

Rejoignez des milliers d'abonnés. Conforme RGPD et CCPA.