Des tests UI qui ressemblent a nos utilisateurs

Les tests d'interfaces graphiques ont une mauvaise réputation. On entend souvent qu'ils sont lents, qu'ils donnent souvent de faux négatifs,... en somme, qu'ils ne sont pas fiables. Une des raisons pour lesquelles ces tests sont compliqués à mettre en place est la difficulté à tester efficacement la partie graphique de nos applications.

Bien heureusement, le tooling autour des tests évolue rapidement. De nouveaux outils tels que Cypress améliorent l'expérience du développeur et peuvent changer la façon dont nous écrivons nos tests.

J'aimerais aujourd'hui vous présenter une stratégie d'écriture de ces tests automatisés d'interfaces graphiques qui me semble tout particulièrement efficace et qui a des effets de bords très intéressants. Le tout en passant par un cas pratique et du code.

Ce que nous voulons tester

Dans cet exemple fictif, nous souhaitons tester l'écran suivant :

Registration form

Il s'agit d'un formulaire d'inscription classique, donc les règles sont les suivantes :

  • L'email de l'utilisateur est requis, et doit respecter un format classique d'email.
  • Le username est également requis.
  • Le password et sa confirmation sont requis.
  • Le password et sa confirmation doivent avoir des valeurs identiques.
  • Lorsque le formulaire est invalide, alors le bouton Register doit être désactivé.

D'un point de vue plus technique, le formulaire est composé :

  • d'un input de type email pour l'email, dont l'ID est email
  • d'un input de type text pour le username, dont l'ID est username
  • d'un input de type password pour le password, dont l'ID est password
  • d'un input de type password pour la confirmation du password, dont l'ID est password-confirmation

Il s'agit volontairement d'un exemple assez simple. Commençons par ce qu'il faut éviter de faire selon moi.

La recette pour des tests UI fragiles

Notre test peut être écrit de la façon suivante :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
describe("Registration form", () => {
  beforeEach(() => {
    cy.visit("http://localhost:3000");
  });

  it("shoud register the user", () => {
    cy.get("button[type=submit]").should("be.disabled");

    cy.get("input#email").type("john.doe@email.com");
    cy.get("button[type=submit]").should("be.disabled");

    cy.get("input#username").type("john_doe");
    cy.get("button[type=submit]").should("be.disabled");

    cy.get("input#password").type("my-password");
    cy.get("button[type=submit]").should("be.disabled");

    cy.get("input#password-confirmation").type("my-pass");
    cy.get("button[type=submit]").should("be.disabled");

    cy.get("input#password-confirmation").type("word");
    cy.get("button[type=submit]").should("be.enabled");

    cy.get("form").submit();
  });
});

Mauvais test UI

Certes, les tests passent, les assertions sont correctes, mais ce test est améliorable. Quels-en sont les défauts ?

Premièrement, ils sont extrêmement couplés au DOM (de par l'utilisation de sélecteurs très précis) :

  • les inputs sont sélectionnés directement avec leurs identifiants
  • le formulaire est soumis impérativement, en utilisant sa méthode submit

A cause de ce couplage, un changement de librairie graphique (qui peut par exemple utiliser des div avec la propriété contenteditable) casseront les tests, alors que la fonctionnalité n'est pas impactée.

De plus, des éléments basiques d'accessibilité tels que la liaison des labels avec leurs inputs ne sont pas validés par ce test : on pourrait très bien retirer l'attribut for des labels, et les tests passeraient toujours.

Mais le défaut le plus important est que ce test n'est pas représentatif de la façon dont l'utilisateur va utiliser notre formulaire. Un utilisateur n'aura que faire des identifiants techniques et des méthodes impératives, il cliquera sur les éléments qui lui parleront.

Les meilleurs tests UI sont ceux qui ressemblent à la façon dont vos utilisateurs utilisent l’application.

Un test qui ressemble à nos utilisateurs

Pouvons-nous nous passer d'identifiants techniques pour écrire nos tests UI ? Cela dépend de nos outils, mais avec Cypress, c'est possible. Voici une version améliorée de notre test :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
describe("Registration form", () => {
  beforeEach(() => {
    cy.visit("http://localhost:3000");
  });

  it("shoud register the user", () => {
    cy.contains("Register").should("be.disabled");

    cy.contains("Your email").click();
    cy.focused().type("john.doe@email.com");
    cy.contains("Register").should("be.disabled");

    cy.contains("Your user name").click();
    cy.focused().type("john_doe");
    cy.contains("Register").should("be.disabled");

    cy.contains("Your password").click();
    cy.focused().type("my-password");
    cy.contains("Register").should("be.disabled");

    cy.contains("Confirm your password").click();
    cy.focused().type("my-pass");
    cy.contains("Register").should("be.disabled");

    cy.focused().type("word");
    cy.contains("Register").should("be.enabled");

    cy.contains("Register").click();
  });
});

Meilleur test UI

Les tests passent toujours, super ! Quels sont les différences avec l'exemple précédent ?

Premièrement, nous ciblons nos éléments du DOM, non pas avec des identifiants techniques, mais avec le helper Cypress contains qui nous renvoie l'élément HTML dont le contenu est donné en argument. Nous utilisons ce qui est affiché sur l'écran de l'utilisateur comme sélecteur.

Ensuite, nous ne cliquons pas directement sur les inputs : nous ciblons les labels (qui est toujours sélectionné avec contains). Ces labels, s'ils sont correctement liés à leurs inputs, leur donneront le focus. Nous utilisons ensuite cy.focused() pour cibler l'élément du DOM qui a effectivement le focus, et nous utilisons la méthode cy.type() pour lui donner une valeur.

Enfin, le formulaire est bien soumis en cliquant sur le bouton "Register", pour s'assurer que son type est bien submit et non pas en appelant directement la méthode .submit() du formulaire.

Ce test n'utilise aucun sélecteur technique : il est entièrement découplé du DOM et teste uniquement la fonctionnalité, sans se soucier de la manière dont les éléments de notre formulaire sont rendus. Il nous permet donc par exemple de changer rapidement de framework graphique tout en conservant notre suite de tests.

De plus, il ne nécessite aucune connaissance des éléments HTML, mais simplement une connaissance de l'interface graphique. Il vérifie également des composantes basiques d'accessibilité.

Enfin, le plus important : il ressemble à la manière dont notre formulaire serait utilisé par un humain.

Néanmoins, comme toute solution, celle-ci est imparfaite, et plus inconvénients viendront avec cette approche :

  • Il sera difficile de sélectionner certains boutons (qui contiennent uniquement des icônes par exemple). Dans ce cas précis, il est recommandé de cibler notre élément en utilisant des sélecteurs liés aux attributs d'accessibilité tels que aria-label (qui nous assure que malgré l'icône, un screenreader pourra énoncer le contenu de l'élément en se basant sur ces attributs).
  • Si le label apparait plusieurs fois dans l'interface, il sera plus difficile de cibler le bon élément. Dans ce cas, il est recommandé de ne pas chercher l'élément sur l'ensemble de la page, mais sur une vue plus précise, en utilisant par exemple des data-testid pour restreindre la zone de cherche.

TL;DR

  • Utilisez le moins de sélecteurs techniques possible
  • Basez vous sur le rendu graphique pour cibler vos éléments
  • Cliquez sur les labels pour donner le focus aux inputs

En bref, essayez d'appliquer cette philosophie :

Un mauvais test UI s'écrit avec le DOM sous les yeux. Un bon test UI s'écrit avec l'interface sous les yeux.

Pour les plus curieux, le code se trouve ici : https://github.com/antoinechalifour/e2e-best-practices