Talents en applications - Blogue | Nexapp

Tester son application frontend

Rédigé par Mylene Truchon | Jun 28, 2023 4:00:00 AM

J’ai eu l’opportunité d’être sélectionnée pour présenter une conférence nommée “Apprendre le frontend sans se perdre” à l’édition 2023 de l’événement Le Web à Québec. Cette démarche avait deux objectifs: identifier des pistes d’apprentissage pour les développeurs et les développeuses bloqués dans leur progression et dresser un inventaire de ressources pouvant les intéresser.

 

Pour y parvenir, j’ai épluché de nombreux articles et livres, en plus de recevoir le soutien de certains collègues. Ma recherche a été très fructueuse, même trop fructueuse pour le temps qu’il m’était alloué pour la conférence! Puisque je n’ai pu y partager qu’une fraction de mes découvertes, j’ai entrepris de rédiger une série d’articles qui mettront en lumière le fruit de mon travail.

 

 

Veuillez noter que les exemples de code sont rédigés en React.

 

_________________________________________________________

 

 

J’ai appris à tester une application sur les bancs de l’université. Les professeurs avaient bien fait leur travail, je suis sortie de là avec une foule de notions en tête: les divers types de tests, les techniques de doublures (stub, spy, mock et alouette)... j’avais même entendu parler du test-driven development! Bref, je me croyais, somme toute, bien équipée pour faire face à un projet réel une fois rendu sur le marché du travail.

 

Néanmoins, il y a une avenue que je n’avais pas prévue et la réalité m’a frappée de plein fouet: comment tester une application frontend? Comme je l’ai mentionné, j’avais déjà rédigé une multitude de tests pendant mes études, mais ils avaient tous été écrits dans des projets backend. Mais comment teste-t-on ce qui est rendu visuellement sur une page Web? Comment teste-t-on le frontend?

 

Sur le marché du travail, attitrée à mon premier projet frontend, je me suis alors mise à TOUT tester. Une simple page blanche avec un titre et un bouton pouvait se voir attribuer une riche suite de tests: la police d’écriture est X, le contenu du titre est Y, le bouton apparaît bien sur la page, ce bouton a telle couleur et telle valeur et agit ainsi lorsque je clique dessus, et tutti quanti.  Aucune ligne de code n’échappait à ma volonté salutaire d’avoir une application suffisamment testée.

 

You get a test and you get a test! Everyone gets a test!

 

 

Que devrait-on tester dans l’interface utilisateur?

C’est le moment auquel une collègue m’a approchée pour mettre le couvercle sur la marmite en me prévenant qu’il n’était pas nécessaire de tester une application en étant aussi chirurgicale.

 

En effet, les tests ont évidemment l'avantage de nous promulguer de la confiance en nos fonctionnalités, mais ils ont aussi un coût: il faut les écrire et les maintenir au fur et à mesure que le code change. C'est pourquoi il importe d'écrire des tests, mais pas trop non plus, au risque de se retrouver avec un ratio efforts/apport désavantageux (petite publication intéressante sur ce sujet).

 

Or, l'une des causes les plus communes de ce déséquilibre est l'attention portée aux détails d'implémentation dans les tests. Pour éviter de basculer dans un état où ces derniers ralentissent le développement plus qu'ils n'apportent de l'assurance, pensez à l’adage qui dit que tous les chemins mènent à Rome, car en programmation, rien n’est plus vrai: donnez-moi une fonctionnalité à implémenter et je vous montrerai une multitude de façons d’y parvenir. Dès lors, s’assurer par le biais de tests que telle voie a été prise plutôt qu’une telle autre voie est contreproductif.

 

Ce faisant, l'instruction suivante est essentielle. Affichez-la sur votre réfrigérateur, brodez-la sur votre chandail préféré, faites-la-vous tatouer sur le torse:

 

 

Lorsqu'on teste l'interface utilisateur d'une application frontend, il faut tester ce que les utilisateurs peuvent expérimenter, donc les comportements des composants rendus sur la page et les effets secondaires d’une action.

En d’autres mots, comme l’avance Kent C. Dodds, auteur de la librairie React Testing Library:

 

 

The more your tests resemble the way your software is used, the more confidence they can give you. [...] Think less about the code you are testing and more about the use cases that code supports.

* source: Testing Implementation Details

La couverture des tests

Quand Dodds mentionne de se soucier des cas d'utilisation testés plutôt que du code testé, il fait référence à la couverture des tests. De nombreuses organisations font la promotion de la couverture à 100%, ce qui signifie que 100% des lignes de code du programme sont testées.

 

Il faut dire que sa clarté et le fait qu'elle soit si explicite en font une métrique attrayante. Cependant, elle n'indique pas quelles lignes devraient prioritairement être testées et n'assure pas que l'entièreté des possibles cas d'utilisation sont couverts par les tests.

 

Dans cet exemple tiré d'une présentation donnée par Kent C. Dodds, une simple fonction, qui transforme la valeur reçue en paramètre en un tableau, est testée.

 

// transforme un argument en array
function arrayify(value: unknown) {
  // simple retour si l'argument était déjà un array
  if (Array.isArray(value)) return value;

  // sinon, l’argument, si truthy, est mis dans un array
  return [value].filter(Boolean);
}


// teste la première ligne (if)
test("returns an array if given an array", () => { 
  expect(arrayify(["cat", "dog"]).toEqual(["cat", "dog"]);
});


// teste la seconde ligne (return)
test("returns an array with the given argument if not an array", () => { 
  expect(arrayify("Leopard")).toEqual(["Leopard"]);
});

Le premier test s'attarde à la première ligne de la fonction, tandis que le second s'attarde à la seconde ligne. Un outil de code coverage indiquerait dès lors que le code est couvert à 100% par des tests. Le développeur non aguerri, alors satisfait, passerait à un autre appel. Mais êtes-vous en mesure d'identifier quel cas d'utilisation de la méthode n'est pas testé?

 

La réponse:

test("return an empty array if given a falsy value", () => { 
  expect(arrayify()).toEqual([]);
});

Tester les cas d'utilisation

"Think less about the code you are testing and more about the use cases that code supports." disait Kent C. Dodds. Pour y parvenir, deux angles d'approche existent:

  • tester les interactions avec ce qui est rendu visuellement (par exemple, si un utilisateur clique sur un bouton) et les effets de ces interactions;
  • tester les effets selon les valeurs passées aux propriétés des composants (par exemple, si la valeur true est passée pour une propriété isLoading).

Il faut en effet s'assurer de tester l'interface utilisateur (le UI) en respectant le concept de “boîte noire” (vérifier que les sorties obtenues correspondent aux entrées données). Cela signifie qu’on évite de tester les méthodes de cycles de vie (souvent des hooks avec des noms du genre beforeCreate, mounted, beforeUnmount), les event handlers (par exemple onclick ou onchange), les states internes d’un composant ou autres détails d'implémentation.

 

Il faut s'en tenir aux entrées (les valeurs passées aux propriétés ou les actions réalisées sur le UI) et aux sorties (les effets visibles dans le UI).

Un cas concret pour mieux comprendre

Voici un cas bien simple: l'intégration d’un simple bouton, le CustomButton, qui lance une action lorsque l’utilisateur clique dessus. Il peut être désactivé (disabled) selon certaines conditions.

 

Un bouton bleu sur lequel il est écrit UPLOAD

const CustomButton = ({ value, onClick, disabled }: Props) => {
  // …

  return (
    <button onclick={onClick} disabled={disabled}>
      { value.toUpperCase() }
    </button>
  );
};



// utilisation du composant CustomButton
// si un fichier a préalablement été sélectionné, le bouton est cliquable
// au clic, la fonction uploadFile est appelée

const UploadFilePage = () => {
  // ...

  return (
    <CustomButton 
      value="upload" 
      onClick={uploadFile} 
      disabled={!selectedFileToUpload} 
    />
  );
};

Avant d’analyser les cas suivants, rappelez-vous une dernière fois le sacro-saint principe des tests: “Il faut tester les comportements et les effets secondaires.”

 

Vous devez tester les comportements et les effets secondaires

Tester les propriétés passées

Bien qu’il soit généralement de bon augure de tester ce qu’il se produit selon les valeurs que vous passez aux propriétés des composants, il n’est pas pour autant nécessaire de toutes les tester. Les plus pertinentes sont celles qui sont liées aux comportements et aux effets secondaires. Celles qui ne font qu'afficher un message, par exemple, sont généralement assez triviales et ne nécessitent pas nécessairement un test.

 

❌ Cas 1
Si la valeur “upload” est passée à la propriété value, le bouton affiche le texte “UPLOAD”.

 

Dans l'optique de ce qui est soulevé dans le paragraphe précédent, il importe encore moins qu'une suite de tests s'assure qu'un texte s’affiche en majuscules! Dans ces situations, faites alors comme Pierre-Yves McSween et demandez-vous: “En as-tu vraiment besoin?”

 

✅ Cas 2
Dans une fenêtre modale de confirmation suppression, le nom de l'élément qui sera supprimé est affiché

 

Gardez néanmoins en tête qu’il peut malgré tout y avoir certains scénarios que l’on préfère inclure dans les suites de tests malgré tout. Un exemple probant de cela est de s’assurer qu’un message d'avertissement contienne le nom du fichier qui sera supprimé si l’utilisateur clique sur le bouton de confirmation. Il s'agit d'une action destructive et il est souhaitable que l'on veille à ce que l'utilisateur soit bien informé de ce qu'il s'apprête à faire.

 

Une fenêtre modale d’avertissement affichant le message “Are you sure you want to delete “rainy_day.jpeg”? You can’t undo this action.”


Bref, ce qu’il faut retenir, ce n’est pas qu’il est strictement interdit de tester ce genre de propriétés, mais plutôt qu’il n’est vraiment pas nécessaire de le tester systématiquement.

 

 

✅ Cas 3
Le bouton est désactivé ou non selon si un fichier est sélectionné ou non (la valeur true ou false est passée à la propriété disabled)

 

Dans ce cas-ci, l’effet qui survient selon si l’on passe une valeur ou une autre à la propriété affecte la manière dont l’utilisateur peut utiliser le produit, donc c’est un effet secondaire que l’on souhaite vivement tester.

Tester le style

Cas 1
Le bouton est bleu.

 

En plus de ne pas être un comportement, le style est souvent fragile, car amené à changer au cours des diverses itérations ou selon l’appareil sur lequel l’utilisateur navigue. De plus, qu’importe, par exemple, si une marge mesure 10 ou 12 pixels, on ne veut pas être alertés pour un changement si minime.

 

Cas 2
Le bouton est rouge en cas d’erreur

 

On teste rarement le style, mais il demeure qu’il peut y avoir quelques cas intéressants. Dans ce cas-ci, il s’agit d’un effet provoqué par un événement et visant à transmettre une information à l’utilisateur, ce qui correspond généralement à ce que l’on souhaite inclure dans nos tests.

Tester l'apparition et l’interactivité des éléments

Cas 1
Le bouton apparaît dans la page

 

Une bonne suite de tests valide nécessairement qu’un composant présent dans une page agit comme attendu lorsque l’utilisateur interagit avec. Dès lors, par défaut, ces tests exigent de détecter la présence desdits composants. Un test qui ne fait que valider la présence d’un composant dans la page est ainsi redondant puisque le reste de votre suite de tests échoue nécessairement si le composant n’est pas présent de toute façon. Bref, même sans ce test, un développeur qui supprime un composant sera avisé des effets de son action par le biais des autres tests.

 

Cas 2

Le bouton apparaît dans la page si...

 

Ceci étant dit, comme on souhaite tester les effets secondaires, il est pertinent de tester le rendu visuel conditionnel d'un composant. L'un des cas fréquents que l'on peut voir est de tester l'apparition d'un message d'erreur à l'écran en cas d'erreur.

 

Cas 3
Quand l’utilisateur clique sur le bouton, X survient

 

Il n’y a pas grand-chose d’autre à dire outre que c’est exactement le type de cas que l'on souhaite voir dans les suites de tests considérant que l'on teste un cas d'utilisation. C'est un test très près de la façon dont l'application est utilisée par les utilisateurs.

 

Et comment tester tout cela?

Vous connaissez probablement la pyramide des tests, qui statue que les projets devraient principalement inclure des tests unitaires, puis certains tests d’intégration et, enfin, une poignée de tests end-to-end. Mais avez-vous déjà entendu parler du trophée de tests?

 

La pyramide de tests et le trophée de tests


La théorie du testing trophy est inspirée d’un gazouillis publié en 2016 par Guillermo Rauch, auteur de Socket.io et autres technologies JavaScript.

 

Write tests. Not too many. Mostly integration.


"Write tests. Not too many." Nous venons d'expliquer pourquoi.

 

Mais dans son article, dont le titre est un clin d'œil à Rauch, Kent C. Dodds justifie la dernière partie de ce mantra: certes, les tests unitaires roulent plus rapidement et sont plus faciles à écrire et à maintenir que les autres types de tests. Ceci étant dit, plus nous montons dans le haut de la pyramide, plus nos tests sont complets et fiables.

 

Il renchérit ensuite:

 

“ So while having some unit tests to verify these pieces work in isolation isn't a bad thing, it doesn't do you any good if you don't also verify that they work together properly. And you'll find that by testing that they work together properly, you often don't need to bother testing them in isolation.”

Qu’est-ce qu’un test d’intégration et pourquoi les préférer?

On distingue généralement trois grands types de tests:

  • les tests end-to-end, qui testent un parcours utilisateur critique de bout en bout (par le biais d’outils tels que Cypress E2E, Playwright et Selenium);
  • les tests d’intégration, qui testent un parcours utilisateur mineur au sein d’une seule page (à l’aide d’outils tels que Cypress Components Testing et Testing Library);
  • les tests unitaires, qui testent un élément en isolation (grâce à des outils tels que Jest et Vitest).

À titre d’illustration, un test end-to-end appliqué sur un outil dans laquelle un utilisateur peut créer des todos et générer une liste de todos testerait ce type de parcours complet:

  • l’utilisateur se connecte sur son compte;
  • sa liste de tâches s’affiche dans la page d’accueil;
  • il clique sur le bouton “supprimer” vis-à-vis l’une de ces tâches;
  • une fenêtre modale s’ouvre;
  • il clique sur “oui” pour procéder;
  • la liste ne contient alors désormais plus cette tâche.

En contrepartie, un test d’intégration est moins étendu et vérifie que la colle entre deux composants est bien présente et qu’ils interagissent bien ensemble.

// interaction entre plusieurs composants (bouton, modale)

test("when I click on DELETE, the modal opens", async () => {
  renderHomePage();
  
  // quand je clique sur DELETE
  const user = userEvent.setup();
  const button = getByRole("button", {name: "DELETE"});   
  await user.click(button);

  // la fenêtre modale s’ouvre
  const modal = getByRole("heading", {name: "Delete File"});
  expect(modal).toBeVisible();
});

Sans ces tests de plus haut niveau, il n'est pas possible de garantir que l'application fonctionne réellement comme prévu. Les tests unitaires à eux seuls ne suffisent pas pour apporter cette confiance.

Vous avez besoin d'être convaincu.e?

 

expect(umbrellaOpens).toBe(true) (source: @erinfranmc)

Les tests d’intégration suffisent-ils pour l’entièreté du code?

Cela signifie-t-il alors que sonne le glas pour les tests unitaires? Est-il l’heure de les expédier, dans un grand ménage du printemps, à la poubelle, aux côtés de nos vieilles chaussures et de nos chandails troués?

 

Nous venons de le dire, pour tester l’interface utilisateur, une grande partie des acteurs du monde de la programmation frontend semble prioriser les tests d’intégration aux tests unitaires. Ceci étant dit, une application contient généralement également de la logique d’affaires et fait appel à divers services externes.

 

Dans un premier temps, pour obtenir une architecture maintenable, le domaine d'affaires doit demeurer indépendant de l'interface utilisateur. On souhaite donc éviter de le tester en passant par le rendu visuel. Dans un second temps, ces éléments viennent aussi souvent avec leur lot d’exceptions et de cas limites qu’il prévaut de tester via des tests unitaires.

 

Au sein d’une architecture frontend avisée, les éléments du domaine d’affaires et l’intégration de ressources externes, qui sont isolés dans des fichiers à part plutôt que d'être tout entremêlés à travers le code responsable du rendu visuel, sont faciles à tester unitairement.

 

 

Les objets du domaine et les appels aux services externes sont séparés dans des fichiers, hors du UI (source: https://martinfowler.com/articles/modularizing-react-apps.html )


S'ils ne sont pas isolés, il devient difficile de les tester sans passer par le rendu visuel des composants, ce qui complexifie les tests et les rend plus coûteux. Voici un exemple assez bêta pour l’illustrer. Dans les deux cas, le fonctionnement d’une méthode utilisée dans un composant nommé HomePage et intitulée  getValueDependingOnCondition() est testé.

// ** 1ER CAS: LA LOGIQUE EST INTÉGRÉE AU UI **

// composant testé

const HomePage = ({ value, onClick, disabled }: Props) => {
  // ...

  const getValueDependingOnCondition = (condition: boolean) => {
    // si true, la string A
    // si false, la string B
  }

  return (
    <p>You picked { getValueDependingOnCondition(condition) }</p>
  );
};



// l’un des tests qui valident le comportement attendu

test("when condition is true, then A is displayed", async () => {
  // rendu visuel de la page
  renderHomePage();
  
  // actions pour que la condition testée (true) se réalise
  const user = userEvent.setup();
  const button = getByRole("button", {name: "activate true condition"});   
  await user.click(button);

  // il faut passer par le rendu pour valider le comportement attendu
  const displayedTextA = getByText("You picked A");
  expect(displayedTextA).toBeVisible();
});

Dans ce premier cas, on remarque que le test est assez complexe. Dans un scénario où il pourrait y avoir plusieurs cas limites et d’exceptions à tester, ou encore si la méthode pouvait retourner encore plus de résultats différents, la suite de tests serait rapidement gonflée par tous ces différents scénarios. Or, en isolant toute cette logique, comme dans l’exemple qui suit, il suffit de tester seulement les cas pertinents dans les tests d’intégration du UI et de tester toutes les diverses possibilités, tous les cas limites ou encore tous les cas plus problématiques dans des tests unitaires du service ou de l’objet du domaine d’affaires.

// ** 2E CAS: LA LOGIQUE EST ISOLÉE DANS UN FICHIER SÉPARÉ **

// fichier à part où la logique se trouve

class Picker {
  getValueDependingOnCondition(condition: boolean) {
    // si X, retourne la string A
    // si Y, retourne la string B
  }
};



// composant qui utilise la logique

const HomePage = ({ value, onClick, disabled }: Props) => {
  // …
  const picker = new Picker();

  return (
    <p>You picked { picker.getValueBasedOnCondition(condition) }</p>
  );
};



// le même test que précédemment, qui valide le comportement attendu

test("when condition is true, then A is returned", async () => {  
  // la méthode est maintenant directement testée
  const picker = new Picker();
  
  // plus besoin du UI pour contrôler la valeur de la condition
  const actualValue = picker.getValueBasedOnCondition(true);

  expect(actualValue).toEqual("A");
});

Contrairement à son prédécesseur, ce test n’a pas à passer par des intermédiaires (le rendu visuel) pour confirmer la réalisation du comportement souhaité. On a ainsi les coudées franches pour tester en profondeur tous les cas possibles.

 

Quelques dernières astuces pour la route

Utiliser le langage spécifique au domaine

Cet article rédigé par l'un de mes collègues explique bien le concept, mais pour résumer, il s’agit en quelque sorte de cacher les détails liés à une librairie spécifique dans des méthodes ou des objets nommés qui décrivent plus explicitement le domaine d’affaires, c'est-à-dire les composants visibles dans les pages et les actions réalisées.

 

Dans le scénario suivant, le composant testé, TodoList, est une liste de tâches à effectuer (une todo list). Le composant fait un appel au backend dans une méthode nommée getTodos() pour obtenir les données à afficher (ce qui implique qu'il faut utiliser un mock dans les tests pour contrôler les données reçues lors de cet appel). Enfin, le composant TodoList a aussi un bouton “COUNT TODOS”, qui affiche une mention à l’écran du nombre de tâches qu’il y a dans la liste.

 

Une liste de tâches, de même qu'un bouton pour compter le nombre de tâches


Voici un premier test rédigé à l’aide de React Testing Library et de la librairie user-event.

// test sans l’usage du langage spécifique au domaine

test("given todos, when clicking on COUNT TODOS, then the quantity of todos is displayed", async () => {
    // GIVEN
    const fetchedTodos= [ /* array de todos */];
    
    // mock du client qui imite un appel API et retourne des données   
    vi.spyOn(
     TodosClient.prototype, 
     "getTodos"
    ).mockResolvedValue(fetchedTodos);
    
    // rendu visuel de la page pour tester les comportements
    render(<TodoList />);
  
    // WHEN
    // clic sur le bouton "COUNT TODOS"
    const user = userEvent.setup();
    const button = screen.getByRole("button", {name: "COUNT TODOS"});   
    await user.click(button);

    // THEN
    // le nombre de todos est affiché à l’écran
    const todosQty = screen.getByText(fetchedTodos.length.toString());
    expect(`${todosQty} todos`).toBeVisible();
});

Voici le même test, mais cette fois-ci, le langage employé rend les diverses étapes plus explicites:

  • les éléments visuels de l’interface ont été réunis dans l’objet ui afin d’alléger les tests;
  • une méthode a été créée pour la condition given todos;
  • les trois méthodes à la fin du fichier font en sorte que les tests sont centrés sur le domaine plutôt que sur les librairies utilisées.
// test avec l’usage du langage spécifique au domaine

const ui = {
  countTodosButton: screen.getByRole("button", {name: "COUNT TODOS"}),
  countText: (todosQty: string) => screen.getByText(`${todosQty} todos`)
};


test("given todos, when clicking on COUNT TODOS, then the quantity of todos is displayed", async () => {
  // GIVEN
  const fetchedTodos= [/* ... */];
  givenTodos(fetchedTodos);
  renderTodoList();
  
  // WHEN
  clickOnCountTodosButton();

  // THEN
  expect(ui.countText(fetchedTodos.length.toString()).toBeVisible();
});



function givenTodos(todos: Todo[]) {
  vi.spyOn(
   TodosClient.prototype, 
   "getTodos"
  ).mockResolvedValue(todos);
}

function renderTodoList() {
  render(<TodoList />);
}

function clickOnCountTodosButton() {
  const user = userEvent.setup();
  await user.click(ui.countTodosButton);
}

Utiliser des méthodes utilitaires

Toujours dans l’application de todos, il est cette fois-ci question de tester la page qui affiche les détails d’une tâche spécifique, TodoDetailsPage, lorsqu'on clique dessus. Le composant testé a une propriété dont il reçoit la valeur de son parent: le todo dont il doit afficher les détails.

 

À l'ouverture de la page, certaines informations doivent être téléchargées. Lors du temps d'attente, un spinner est affiché.

 

Pour pouvoir tester les divers scénarios, il faut alors faire le rendu de ce composant avec diverses valeurs passées à la propriété todo, dont certaines seront valides et d’autres non. Pour faciliter la tâche, il peut être opportun de créer une méthode utilitaire qui facilitera la création de todos de test en test. De plus, TypeScript offre le type utilitaire Partial et le spread operator (...) pour rendre les choses encore plus simples.

const ui = { /* ... */ }

// pour ce test, la valeur du todo n’importe pas
// on utilise createTodo telle quelle sans lui passer d’arguments

test("given todo is loading, the a spinner is displayed", async () => {
  givenLoading();
  renderTodoDetailsPage(createTodo());
  
  expect(ui.spinner).toBeVisible();
});


// la date du todo doit être expirée
// createTodo est utilisée avec un todo partiel
// qui inclut spécifiquement ce qui est pertinent: dueFor
  
test("given todo with expired date, error message is shown", async () => {
  const expiredDateTodo = createTodo({ dueFor: new Date("1900-01-01") });
  renderTodoDetailsPage(expiredDateTodo);
  
  expect(ui.expiredDateWarningMessage).toBeVisible();
});



function createTodo(todo?: Partial<Todo>) {
  return {
    description: "any valid description",
    dueFor: new Date(),
    assignedTo: "any valid person",
    ...todo // spread operator
  };
}

function renderTodoDetailsPage(todo: Todo) {
  render(<TodoDetails todo={todo} />);
}

// ...

Utiliser le patron Builder plutôt que des méthodes utilitaires

Si une méthode utilitaire s’avère trop complexe (ou simplement parce qu’on en a envie), il peut-être intéressant d’implémenter un builder à la place. L’exemple qui suit est fortement inspiré d’une publication LinkedIn de Nicolas Carlo.

// test sans le builder

test("given todos with same due date and one is urgent, then...", () => {
  const urgentTodo = {
    id: "an id",
    description: "finish homework",
    dueFor: new Date(A_DATE),
    assignedTo: "someone",
    responsible: "a person",
    isUrgent: true,
    difficultyLevel: DifficultyLevel.HARD
  };
  const notUrgentTodo = {
    id: "another id",
    description: "clean bathroom",
    dueFor: new Date(A_DATE),
    assignedTo: "another person",
    responsible: "someone else",
    isUrgent: false,
    difficultyLevel: DifficultyLevel.MEDIUM
  };
  
  // la création de ces objets prend beaucoup de lignes
  // en outre, plusieurs des champs du type Todo pourraient être cachés
  // car ils n'importent pas dans le contexte du test

  const urgentTodos = getUrgentTodos([urgentTodo, notUrgentTodo]);

  expect(urgentTodos).not.toContain(notUrgentTodo);
});


// même test, mais avec un builder
test("given todos with same due date and one is urgent, then...", () => {
  const urgentTodo = createTodo()
    .withId("an id")
    .withDueFor(A_DATE)
    .isUrgent()
    .build()  // retourne la valeur
    
  const notUrgentTodo = createTodo()
    .withId("another id")
    .withDueFor(A_DATE)
    .isNotUrgent()
    .build()  

  const urgentTodos = getUrgentTodos([urgentTodo, notUrgentTodo]);

  expect(urgentTodos).not.toContain(notUrgentTodo);
});

Éviter d’avoir trop d’imbrication

Plusieurs librairies JavaScript de tests frontend ont une syntaxe assez similaire: describe, test/it, beforeEach, afterEach, etc.

describe("XYZ Page", () => {
  beforeEach(() => {
    // ...
  });

  afterEach(() => {
    // ...
  });

  test("...", () => {
    // ...
  });

  test("...", () => {
    // ...
  });
};

Certains encouragent l’usage de cette syntaxe pour l’implémentation du format “given/when/then” (ou “arrange/act/assert”), en mettant le given et le when dans des blocs describe, ce qui force également la réalisation de l’action du when dans un bloc beforeEach.

describe("Todo List", () => {
  describe("given todos", () => {
  
    const fetchedTodos= [/* ... */];
    givenTodos(fetchedTodos);
    renderTodoList();

    describe("when clicking on COUNT TODOS", () => {
      beforeEach(() => {
        clickOnCountTodosButton();
      });

      it("displays the quantity of todos", () => {
        expect(
          ui.countText(fetchedTodos.length.toString()
        ).toBeVisible();
      });
    }
  }
};

Bien que cette pratique persiste au sein de plusieurs organisations, il est déconseillé de l’employer pour des raisons de lisibilité et de maintenabilité. Plutôt, on préfère éviter les boilerplates autant que possible. Cet article de Kent C. Dodds, de même que cet article rédigé par mon collègue, abordent ce sujet de façon plus détaillée.

test("given todos, when clicking on COUNT TODOS, then the quantity of todos is displayed", () => {

  const fetchedTodos= [/* ... */];
  givenTodos(fetchedTodos);
  renderTodoList();
  
  clickOnCountTodosButton();

  expect(ui.countText(fetchedTodos.length.toString()).toBeVisible();
});