Talents en applications - Blogue | Nexapp

Tester React: Éviter l'imbrication

Rédigé par Alexandre Rivest | Jun 28, 2023 4:00:00 AM

Parfois, écrire des tests pour un système nécessite de réaliser des opérations plusieurs fois à travers plusieurs comportements. Ces opérations peuvent représenter une configuration initiale, une action générique, etc. Les librairies de tests Javascript telles que Jest, Vitest et Mocha offrent des méthodes utilitaires tel que beforeEach et afterEach pour aider le développeur à réduire la duplication de code. Ces fonctions sont cependant un cheval de troy! On pense qu’elles apportent une meilleure lisibilité aux tests alors qu’elles peuvent facilement nuire plus qu’autre chose! Nous verrons donc en quoi ils peuvent nuire à nos tests et quelles sont nos alternatives.

 

Prenons comme exemple une page de connexion dont le bouton est seulement cliquable si le courriel et le mot de passe ne sont pas vides.

import type { FormEvent } from 'react';
import { useState } from 'react';

interface Props {
 onLogin: (email: string, password: string) => void;
}

export function Login({ onLogin }: Props) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    onLogin(email, password);
  };

  const formImcomplete = email === '' || password === '';

  return (
    <form onSubmit={submit}>
      <label htmlFor="email">Email</label>

      <input
        id="email"
        name="email"
        type="text"
        onChange={event => setEmail(event.target.value)}
      />

      <label htmlFor="password">Password</label>

      <input
        id="password"
        name="password"
        type="password"
        onChange={event => setPassword(event.target.value)}
      />

      <button type="submit" disabled={formImcomplete}>Submit</button>
    </form>
  );
}

Commençons avec un premier test qui valide l’état initial du formulaire.


describe('Login', () => {
 test('should have the submit button initially disabled', () => {
   render(<Login />);

   expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
 });
});

Allons voir le résultat du test:

Notre test passe, parfait! Passons au prochain cas: lorsque le courriel est rempli, le bouton devrait toujours être désactivé, car il faut que tous les champs soient remplis!‌‌


describe('Login', () => {
 test('should have the submit button initially disabled', () => {
   render(<Login />);

   expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
 });

 test('should have the submit disabled given only the email is filled', () => {
   render(<Login />);

   const emailInput = screen.getByLabelText('Email');
   userEvent.type(emailInput, 'email@email.ca');

   expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
 });
});

On constate ici qu’il y a 2 éléments dupliqués dans nos tests: le render et le expect. Utilisons la méthode beforeEach pour réduire la duplication! Malgré le fait qu’il existe afterEach de disponible pour nos tests, il est préférable de garder les assertions à même le test pour garder un minimum de cohérence. Profitons aussi du moment de refactoring pour commencer un Domain Specific Language de notre page:


describe('Login', () => {
 beforeEach(() => {
   render(<Login />);
 });

 test('should have the submit button initially disabled', () => {
   expect(submitButton()).toBeDisabled();
 });

 test('should have the submit disabled given only the email is filled', () => {
   const emailInput = screen.getByLabelText('Email');
   userEvent.type(emailInput, 'email@email.ca');

   expect(submitButton()).toBeDisabled();
 });

 const submitButton = () => screen.getByRole('button', { name: 'Submit' });
});

Ajoutons le cas où l’utilisateur entre le courriel et ensuite le mot de passe! Étant donné que l’ajout d’un courriel est présent dans le test précédent, nous pouvons créer un nouveau describe avec son propre beforeEach.


describe('Login', () => {
 beforeEach(() => {
   render(<Login />);
 });

 test('should have the submit button initially disabled', () => {
   expect(submitButton()).toBeDisabled();
 });

 describe('when filling the email', () => {
   beforeEach(() => {
     const emailInput = screen.getByLabelText('Email');
     userEvent.type(emailInput, 'email@email.ca');
   });

   test('should have the submit disabled', () => {
     expect(submitButton()).toBeDisabled();
   });

   describe('and filling the password', () => {
     test('should enable the submit button', () => {
       const passwordInput = screen.getByLabelText('Password');
       userEvent.type(passwordInput, 'password123');

       expect(submitButton()).toBeEnabled();
     });
   });
 });

 const submitButton = () => screen.getByRole('button', { name: 'Submit' });
});

Nous avons maintenant une suite de tests qui valide le formulaire vide, avec seulement un champ et lorsque les deux champs sont remplis! Bien que les tests satisfont les critères d’acceptation, on commence à avoir des tests assez éparpillés à travers le fichier au complet. Les noms des tests subissent d’ailleurs le même sort, il faut maintenant composer les describes avec la fonction de test pour comprendre ce que le test valide. Bien que notre suite de tests soit courte et simple, je vous laisse imaginer un fichier de 50 tests pour un UI plus complexe!

Réduction d’indentation par composition

Si les beforeEach nuisent à la lisibilité du code, comment faire pour éviter la duplication? Une partie de la solution a déjà été présentée dans les exemples ci-dessus. Le beforeEach est expliqué par le nom du describe, c’est lui qui représente l’intention de la fonction. Cette intention est généralement spécifique au contexte de ce qu'on teste, donc il pourrait faire partie de notre DSL! Si l’on applique cette technique de test pour augmenter la lisibilité, voici à quoi pourrait ressembler notre plan de refactor:

 

1. Encapsuler le contenu du beforeEach dans une fonction nommée dans le même style que le describe, donc de se créer un DSL

2. Utiliser la fonction à même les tests

3. Retirer le describe avec son beforeEach et de mettre à jour le nom du test afin de le rendre plus clair.


Si l’on regarde uniquement le test qui valide que le bouton est désactivé si seulement le courriel est rempli:‌‌


describe('Login', () => {
 beforeEach(() => {
   render(<Login/>);
 });
 
 describe('when filling the email', () => {
   beforeEach(() => {
     const emailInput = screen.getByLabelText('Email');
     userEvent.type(emailInput, 'email@email.ca');
   });

   test('should have the submit disabled', () => {
     expect(submitButton()).toBeDisabled();
   });
 });

 const submitButton = () => screen.getByRole('button', { name: 'Submit' });
});

Et que nous appliquons étape par étape notre recette, on se retrouverait avec:

 

1. Créer un DSL


describe('Login', () => {
 beforeEach(() => {
   initLogin();
 });

 describe('when filling the email', () => {
   beforeEach(() => {
     enterEmail('email@email.ca');
   });

   test('should have the submit disabled', () => {
     expect(submitButton()).toBeDisabled();
   });
 });

 const initLogin = () => render(<Login/>);

 const submitButton = () => screen.getByRole('button', { name: 'Submit' });

 const enterEmail = (email: string) => {
   const emailInput = screen.getByLabelText('Email');
   userEvent.type(emailInput, email);
 };
});

2. Utiliser notre DSL à même le test


describe('Login', () => {
 beforeEach(() => {});
 
  describe('when filling the email', () => {
   beforeEach(() => {});
  
   test('should have the submit disabled', () => {
     initLogin();
    
     enterEmail('email@email.ca');

     expect(submitButton()).toBeDisabled();
   });
 });

 const initLogin = () => render(<Login/>);

 const submitButton = () => screen.getByRole('button', { name: 'Submit' });

 const enterEmail = (email: string) => {
   const emailInput = screen.getByLabelText('Email');
   userEvent.type(emailInput, email);
 };
});

3. Renommer le test et retirer le code inutile


describe('Login', () => {
 test('should have the submit disabled when only the email is filled', () => {
   initLogin();

   enterEmail('email@email.ca');

   expect(submitButton()).toBeDisabled();
 });

 const initLogin = () => render(<Login/>);

 const submitButton = () => screen.getByRole('button', { name: 'Submit' });

 const enterEmail = (email: string) => {
   const emailInput = screen.getByLabelText('Email');
   userEvent.type(emailInput, email);
 };
});

Nous avons maintenant un test qui contient tout ce qui est primordial à sa compréhension, il n’est plus éparpillé un peu partout. Si l’on applique cette recette de façon itérative, nous obtiendrons éventuellement une suite de test qui ressemblerait à:


describe('Login', () => {
 test('should have the submit button initially disabled', () => {
   initLogin();

   expect(submitButton()).toBeDisabled();
 });

 test('should have the submit disabled when only the email is filled', () => {
   initLogin();

   enterEmail('email@email.ca');

   expect(submitButton()).toBeDisabled();
 });

 test('should enable the submit button when both fields are filled', () => {
   initLogin();

   enterEmail('email@email.ca');
   enterPassword('password123');

   expect(submitButton()).toBeEnabled();
 });

 const enterPassword = (password: string) => {
   const passwordInput = screen.getByLabelText('Password');
   userEvent.type(passwordInput, password);
 };

 const enterEmail = (email: string) => {
   const emailInput = screen.getByLabelText('Email');
   userEvent.type(emailInput, email);
 };

 const submitButton = () => screen.getByRole('button', { name: 'Submit' });

 const initLogin = () => render(<Login onLogin={jest.fn()} />);
});

Avec ce format de test, il est d’ailleurs plus clair que notre suite de tests est incomplète! Il nous manque le test qui valide que le bouton soumettre est désactivé s’il y a seulement le mot de passe qui est rempli. Avec notre nouveau DSL, c'est d’ailleurs très facile de bâtir notre test:


test('should have the submit disabled when only the password is filled', () => {
 render(<Login />);

 enterPassword('password123');

 expect(submitButton()).toBeDisabled();
});

Nous avons maintenant une suite de tests complète sur le comportement du bouton de soumission de notre composant.

S’aider à comprendre

En quoi ça aide vraiment la lisibilité? Une des explications derrière cette pratique vient de la façon dont fonctionne la mémoire à court terme. Elle retient temporairement (moins d’une minute) un nombre limité d’informations (environ 4), avant que celle-ci soit stockée plus longtemps dans notre cerveau en mémoire à long terme. De surcharger la mémoire court terme rend difficile la compréhension globale des choses. En programmation, il est possible de voir chaque niveau d’indentation comme un contexte. Dans l’exemple initial de cet article, nous avions 5 contextes seulement pour le test de courriel rempli: 2 describes, 2 beforeEach et le contenu du test. Suite au refactor, nous n’en avions que 2: 1 describe et le contenu du test. L’utilisation du DSL aide notre mémoire à court terme lorsqu'on lit du code, car il vient abstraire toute information qui pourrait ne pas être importante à notre compréhension.

 

On pourrait voir cette façon de faire comme à l’écriture d’un article de journal. Les informations primordiales à la nouvelle se situent dans le titre et dans l’entête de la page. Si l’on est intéressé à plus de détails sur la nouvelle, on descend la page pour voir le contenu. C’est semblable lorsqu'on lit nos tests:

  • Le nom du test est le titre de notre article
  • Le contenu du test est l’entête
  • Les méthodes de nos DSL sont le détail

Avec l’utilisation de beforeEach, c’est comme si plusieurs articles se retrouvaient dans la même page pêle-mêle au lieu d'un à la suite de l’autre.

Plusieurs problèmes, plusieurs solutions

Dans le développement logiciel, j’ai souvent entendu la phrase: On utilise X solution à cet endroit, pourquoi on peut juste la réutiliser ici aussi! À cela je dis: Pas si vite! Cette solution se résume très souvent à une technologie. Dans l’écosystème de React, Redux est le parfait exemple. L’absence de standard et recommandation dans comment utiliser cette librairie a fait en sorte que beaucoup de développeurs ont décidé de l'utiliser partout sans se poser la question si c’était la bonne solution. Avec le temps des problèmes de performances survenaient dans les projets suivant cette approche, en plus d’augmenter la complexité du code et des tests.

 

La même chose se produit avec les beforeEach. Ce n’est pas parce qu’il est présent dans la librairie qu’il faut les utiliser dans tous les tests. Un exemple d’utilisation où les méthodes beforeEach et afterEach sont pertinentes est lorsqu'il y a une configuration globale à faire ou une réinitialisation.


describe('LocalDB', () => {
 beforeAll(() => {
   setupDB();
 });

 afterEach(() => {
   clearDB();
 });

 afterAll(() => {
   closeDB();
 });

 test('given no value, should throw when trying to get a value by id', () => {
   // ...
 });


 // ...
});

Allons dans un cas un peu extrême un moment. Nous avons une suite de tests qui utilise les beforeEach pour enlever de la logique d’un test. Jest nous offre un afterEach, pourquoi ne pas l’utiliser aussi?

describe('Login', () => {
 beforeEach(() => {
   initLogin();
 });

 test('should have the submit button initially disabled', () => {});

 afterEach(() => {
   expect(submitButton()).toBeDisabled();
 });
});

Avec Jest, ce test est considéré valide! Est-ce un test bien structuré? Pas vraiment… Est-ce qu’il y a des cas où l’on voudrait un expect dans le afterEach? Certainement! Une validation technique que l’on veut faire après chaque test, par exemple de s’assurer qu’un objet n’est pas muté, est un bon cas de afterEach ici. L’utilisation de ces fonctions de test reste contextuelle.

Conclusion

Éviter d’utiliser les méthodes beforeEach dans nos tests, combiné avec le DSL, nous permet de grandement simplifier les tests en consolidant tout ce qui est nécessaire au test tout en évitant de dupliquer du code inutilement.

Cet article est grandement inspiré de 2 articles de Kent C Dodds, créateur de Testing-Library: