Article écrit par Lou Augey
Dans cet article sera expliqué en quoi consiste les tests unitaires et comment les mettre en place dans une application React.js. Nous regarderons en détail comment utiliser les bibliothèques Jest et testing-library pour les faire puis comment en déduire une couverture de tests.
Prérequis : Connaître React 16+
Sommaire :
- Pourquoi faire des tests unitaires ?
- Créer et lancer des tests avec Jest
- Tester des composants React avec Testing-Library
- Simuler des événements utilisateurs
- Mocker les composants enfants pour avoir des tests vraiment unitaires
- Obtenir la couverture des tests
Pourquoi faire des tests unitaires ?
Les tests unitaires sont des procédés qui permettent de vérifier le fonctionnement d’une portion de l’application (fonction, composant, etc.) de manière isolée du reste de l’application.
Ils permettent d’automatiser la vérification des fonctionnalités et de pouvoir contrôler tous les cas particuliers que peut rencontrer l’élément testé (paramètres vides ou null, mauvaises props, clic sur un bouton, etc.).
Ainsi, les tests unitaires permettent d’améliorer la robustesse et la fiabilité de l’application en cours du temps, en identifiant plus rapidement les potentiels effets de bords des nouveaux développements. Le fait que les tests soient unitaires donne une granularité permettant de simplifier l’identification des bugs et des régressions.
→ Bien que les tests unitaires soient plus courants dans les applications back-end, ils sont tout aussi importants dans les applications front et permettent de ne pas se reposer uniquement sur des tests fonctionnels.
→ Les tests unitaires côté front sont particulièrement recommandés si l’application est composée par un grand nombre de composants ou si plusieurs développeurs travaillent successivement sur une même application.
→ Certaines équipes encouragent l’utilisation de la méthode Test-Driven Development (TDD) qui consiste à d’abord écrire les tests unitaires et les assertions que doit vérifier le composant, et seulement ensuite d’écrire le composant. Cette approche permet de mieux s’assurer que le composant correspond aux spécificités demandées et être plus efficace pour le développer.
Créer et lancer des tests unitaires avec Jest
Pour créer des tests unitaires dans une application React, il est nécessaire d’utiliser un framework de test Javacript. Le plus adapté à React et le plus utilisé est le framework Jest (https://jestjs.io/fr/).
Installation
Jest est par défaut installé dans les projets créés avec create-react-app (CRA). Il n’y a pas de configuration supplémentaire à faire.
Pour les projets créés sans le CRA, c’est-à-dire avec une configuration de webpack manuel, il est nécessaire d’installer les bibliothèques suivantes :
- jest
- babel-jest
- @babel/preset-env
- @babel/preset-react
Il faut également ajouter un fichier babel.config.js avec la configuration décrite ici : https://jestjs.io/fr/docs/tutorial-react
Écrire un test
Pour écrire un test avec Jest, il faut tout d’abord créé un fichier avec l’extension .test.js et le structurer comme l’exemple ci-dessous :
describe
: Fonction permettant de regrouper une liste de tests, généralement tous les tests associés à un même composant. Cette fonction n’est pas obligatoire, vous pouvez donc faire vos tests directement dans le fichier sans les englober par describe. Regrouper les tests avec describe apporte toutefois une plus grande lisibilité lors du lancement des tests.
test
ou it
: Fonction contenant le test en lui-même. On peut avoir autant de tests qu’on le souhaite dans un fichier. Généralement, on fait un test pour chaque fonctionnalité/cas d’usage. Chaque test se décompose en 3 étapes :
- Génération d’un DOM virtuel à partir d’un composant React. Cette étape est faisable grâce à une bibliothèque telle que testing-library ou Enzyme.
- Actions éventuelles à faire sur le DOM. Exemple : clique sur un bouton.
- Assertions sur le contenu du DOM. Pour faire des assertions avec Jest, il faut utiliser la fonction expect() qui se structure comme ci-dessous :
expect(element-du-DOM).assertion(valeur-attendue-pour-l-element-du-DOM
Voici maintenant un exemple plus concret :
describe("<ComposantListe />", () => {
test("exemple basique sans React", () => {
// 1) Génération
const monTableau = ["élément 1", "élément 2", "élément 3"];
const monTableauNull = null;
// 2) Actions
// 3) Assertions
expect(monTableau).toHaveLength(3);
expect(monTableau[0]).toBe("élément 1");
expect(monTableau[1]).not.toBe("élément 3");
expect(monTableauNull).toBeNull();
});
})
Dans le test présenté ci-dessus, nous créons un simple tableau composé de 3 éléments et une variable égale à null.
La première assertion va vérifier que le tableau a bien une taille égale à 3 avec la méthode toHaveLength().
La seconde assertion va vérifier que le premier élément du tableau est égal à “élément 1” grâce à la méthode toBe().
La troisième assertion va vérifier que le second élément n’est pas égal à “élément 3” en combinant les méthodes not et toBe().
Enfin, la dernière assertion va vérifier que la variable monTableauNull est bien null avec la méthode toBeNull().
→ La liste de toutes les méthodes d’assertions utilisables avec expect est listée ici : https://jestjs.io/fr/docs/expect
Bonne pratique
Pour bien structurer son projet React, il est conseillé de placer les composants dans un répertoire portant le même nom et dans lequel sera placés tous les fichiers relatifs à ce composant : fichier CSS, fichier Storybook, et dans notre cas, fichier de test.
Lancer les tests
Une fois qu’on a un ou plusieurs tests d’écrits, il va falloir les lancer pour vérifier qu’ils passent.
Pour un projet créé avec le CRA, il suffit de lancer la commande suivante dans un terminal : npm run test
Le résultat des tests s’affiche alors dans le terminal :
Par défaut, l’option watch est activée. Cette option permet de lancer seulement les tests qui sont liés à des fichiers modifiés/non comités et donc ne pas lancer systématiquement tous les tests définis dans l’application. L’option watch permet également de relancer les tests automatiquement à chaque nouvelle modification sauvegardée dans les fichiers.
Pour les projets sans CRA, il suffit d’ajouter l’appel à Jest dans les commandes dans le package.json :
"scripts": {
"start": "webpack serve",
"build": "webpack",
"test": "jest --watch", // ligne à ajouter
},
Cas où les tests sont en échecs
À partir de l’exemple précédent, j’ai volontairement ajouté une assertion fausse :
const monTableau = ["élément 1", "élément 2", "élément 2"];
expect(monTableau[0]).toBe("élément 2");
En lançant les tests avec npm run test
, le test où j’ai mis l’erreur est directement indiqué en tant que FAIL et l’interface nous indique :
- Le nom du regroupement de tests concernés (définie par
describe
) - Le nom du test en échec
- La ligne du test qui a provoqué une erreur
- La différence entre la valeur reçue et la valeur attendues
Ainsi Jest nous donne toutes les informations nécessaires pour identifier le test en erreur et pourquoi il n’est pas passé.
→ La bibliothèque Jest nous permet donc de créer des tests en Javascript, avec une panoplie de méthodes d’assertions possibles, et de pouvoir les lancer pour vérifier s’ils sont passés ou non.
→ Pour tester spécifiquement des composants React, il est nécessaire d’utiliser une bibliothèque permettant de générer un DOM virtuel à partir du composant pour pouvoir ensuite faire des assertions avec Jest sur les éléments du DOM générés.
Tester des composants React avec Testing-Library
Pour tester des composants React il est nécessaire d’utiliser une bibliothèque qui va générer un DOM virtuel du composant en question et permettre de faire des assertions avec Jest sur ce qui est présent ou non dans ce DOM. Les deux bibliothèques les plus utilisées sont Enzyme et Testing-library.
Testing-Library vs Enzyme
Avant la version 16 de React et l’introduction des Hooks, c’était la bibliothèque Enzyme (https://enzymejs.github.io/enzyme/) qui était majoritairement utilisée pour les tests des composants React.
Enzyme à deux très gros avantages :
- Permettre de générer un DOM “shallow” comprenant l’équivalent HTML du composant qu’on teste, mais sans générer la partie du DOM correspondant aux composants enfants. Cela permet de faire de vrais tests unitaires : on teste un composant précis, mais pas les autres composants qui seraient appelés dedans.
- Permettre permet également de manipuler directement des états et props des composants.
Mais Enzyme a également les inconvénients suivants :
- Moins bien adapté aux composants de type fonction avec l’utilisation des hooks.
- Pas d’adaptateur officiel pour la version 17 de React
- Aucun adaptateur existant pour la version 18 de React
La bibliothèque Testing-library, plus récente, a été développée directement par les développeurs de React et est indiquée comme la bibliothèque officielle à utiliser. Le fonctionnement de Testing-libray est très différent de Enzyme et donne une approche plus proche de l’expérience utilisateur. On ne peut pas accéder ni aux états, ni aux props de React, ni même aux composants en tant que tels. Il faut à la place sélectionner les éléments du DOM à partir des méthodes proposées par Testing-library.
→ Bien que Enzyme a une approche qui permet de plus facilement tester et manipuler les composants et leurs caractéristiques, cette bibliothèque n’est plus vraiment maintenue et n’est plus utilisable dans la dernière version de React. De ce fait, il est clairement recommandé d’utiliser plutôt Testing-library.
→ L’approche de Testing-library permet également de générer un DOM virtuel qui sera le plus proche de ce qui sera affiché dans les navigateurs, et donc plus proches de la réalité.
Dans la suite de cet article, nous allons nous concentrer sur la bibliothèque Testing-library.
Tester le DOM avec Testing-library
La bibliothèque Testing-library est composée de deux fonctions très utiles :
render : Fonction permettant de générer le DOM virtuel à partir du composant React.
screen : Fonction permettant de « scanner » le contenue du DOM virtuel généré pour chercher un élément. La fonctionscreen
possède plusieurs méthodes permettant de trouver un ou plusieurs éléments du DOM à partir de leur caractéristique. Voici les trois méthodes les plus utilisées :
- screen.getByText() : Permet de chercher un texte dans le DOM. Par défaut, la valeur du texte doit être exacte.
- screen.getByRole() : Permet de trouver un élément ayant un rôle html précis dans le DOM.
- screen.getByTestId() : Si on ajoute l’attribut data-testid sur un des éléments du composant, cette méthode permet de retrouver l’élément à partir de l’id précisé. Exemple :
<div data-testid='custom-element' ></div>
Il y en a bien sûr plus que ces trois là. Vous pouvez la liste complète et leurs détails sur le site de Testing-Library : https://testing-library.com/docs/queries/byrole
Pour la méthode screen.getByRole()
, voici quelques exemples de correspondances rôles ← → base html :
- Rôle
button
← →<button />
- Rôle
link
← →<a></a>
- Rôle
textbox
← →<input type="text" />
- Rôle
checkbox
← →<input type="checkbox" />
- Rôle
table
← →<table></table>
- Rôle
columnheader
← →<th></th>
- Rôle
cell
← →<td></td>
Pour un type d’objet à trouver, par exemple, les rôles, il existe toute une déclinaison de méthodes selon le résultat attendu. Il existe getByRole, mais aussi getAllByRole, queryByRole, findAllByRole, etc.
La documentation officielle donne un tableau récapitulatif des différents types de méthodes possibles selon le résultat attendu :
Prenons à présent un exemple de composant simple, qui affiche un header, un sous-composant <ComposantListe />
, un bouton et un texte qui s’affiche seulement au clique sur le bouton :
import { useState } from "react";
import ComposantListe from "../ComposantListe/ComposantListe"
const ComposantPrincipal = (props) => {
const [isClick, setIsClick] = useState(false)
return (
<div>
<header>Titre de mon composant</header>
<ComposantListe titre="titre de la liste"/>
<button onClick={() => setIsClick(true)}>Cliquer ici !</button>
{isClick &&
<div>Merci d'avoir cliqué</div>
}
</div>
)
}
export default ComposantPrincipal;
Voici maintenant un exemple pour tester la présence du titre en utilisant render et screen.getByText de Testing-Library :
import { render } from "@testing-library/react"
import ComposantPrincipal from "./ComposantPrincipal";
describe("<ComposantPrincipal />", () => {
test("titre", () =>{
// Génération DOM virtuel
render(<ComposantPrincipal />);
// Assertions
expect(screen.getByText("Titre de mon composant")).toBeInTheDocument();
})
})
Ici l’utilisation de getByText permet de vérifier que le texte recherché est bien présent de manière unique dans le DOM.
Maintenant que vous avez vu comment utiliser Jest et Testing-Library pour mettre en place des tests sur les composants React, nous allons voir un aspect essentiel des tests : simuler des événements utilisateurs.
Simuler des événements utilisateurs
Reprenons le composant <ComposantPrincipal /> vu précédemment :
import { useState } from "react";
import ComposantListe from "../ComposantListe/ComposantListe"
const ComposantPrincipal = (props) => {
const [isClick, setIsClick] = useState(false)
return (
<div>
<header>Titre de mon composant</header>
<ComposantListe titre="titre de la liste"/>
<button onClick={() => setIsClick(true)}>Cliquer ici !</button>
{isClick &&
<div>Merci d'avoir cliqué</div>
}
</div>
)
}
export default ComposantPrincipal;
Dans cet exemple, il y a un texte qui s’affiche seulement si un événement utilisateur est déclenché pour cliquer sur le bouton. C’est typiquement le genre de cas qui est utile de tester. Pour cela il est donc nécessaire de simuler ces fameux événements utilisateurs.
Pour cela, il existe une sous-bibliothèque de Testing-Library qui s’appelle user-event et qui permet de simuler les actions utilisateurs. Il faut tout d’abord l’installer dans le projet :
npm install @testing-library/user-event
Pour utiliser cette bibliothèque dans notre test, il faut :
- Importer la bibliothèque dans le fichier de test :
import userEvent from "@testing-library/user-event;
- Initialiser un utilisateur au début du test avec
const user = userEvent.setup()
- Ajouter le mot-clé
async
à la fonction de test pour indiquer qu’il faudra gérer des traitements asynchrones (par exemple des changements d’état) - Ajouter une action utilisateur à l’endroit souhaité dans le test. Par exemple pour simuler un click sur un élément, il y a la méthode
user.click()
- Ajouter le mot-clé
await
devant la méthodeuser.click()
pour indiquer qu’il faut attendre que les traitements asynchrones sont terminés pour passer aux lignes suivantes.
Le test du composant <ComposantPrincipal />
devient alors le suivant :
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ComposantPrincipal from "./ComposantPrincipal";
describe("<ComposantPrincipal />", () => {
test("click sur le button", async () =>{
// initialisation d'un user avec userEvent
const user = userEvent.setup();
// Génération DOM virtuel
render(<ComposantPrincipal />);
// Assertions
expect(screen.queryByText("Merci d'avoir cliqué")).not.toBeInTheDocument();
await user.click(screen.getByRole('button'));
expect(screen.getAllByText("Merci d'avoir cliqué")).toHaveLength(1);
})
})
Dans ce test, on commence donc par initialiser un utilisateur avec user-event, puis on vérifie que le texte “Merci d’avoir cliqué” n’est pas présent dans le DOM avec screen.queryByText()
. On appelle ensuite la méthode user.click()
pour simuler un clic de l’utilisateur et on l’applique sur le bouton présent (qu’on a pu sélectionné avec screen.getByRole('button')
. Le await
est bien présent dans la méthode pour indiquer qu’on attend que les changements asynchrones soient finis. Enfin, on refait une assertion pour vérifier que le texte est bien apparu dans le DOM.
Dans cet exemple nous avons simulé un clique sur un élément, mais user-event nous propose plusieurs autres actions simulables. Pour en lister quelques-unes, nous avons également :
user.keyboard()
pour simuler une action au clavieruser.upload()
pour simuler l’upload d’un fichier dans un inputuser.selectOptions()
Pour sélectionner une option d’un selectuser.hover()
Pour le survol d’un élémentuser.paste()
Pour simuler le collage d’un texte, dans un input par exemple- etc
L’ensemble des actions pouvant être simuler avec user-event sont détaillées dans la documentation officielle : https://testing-library.com/docs/ecosystem-user-event/
Mocker les composants enfants pour avoir des tests vraiment unitaires
Une des particularités de la bibliothèque Testing-Library, c’est qu’en appelant la méthode render
sur un composant, on va générer le DOM virtuel du composant, mais aussi le DOM de tous ses composants enfants. De ce fait, par défaut, les tests faits avec Testing-Library ne peuvent pas être considérés comme “unitaire”.
Pour rendre le test vraiment unitaire, il y a cependant une solution : Mocker les composants enfants avec Jest.
Reprenons l’exemple de notre composant <ComposantPrincipal />
qui est composé du composant enfant <ComposantListe />
.
→ Commençons par définir une fonction simulée avec Jest et la méthode jest.fn()
:
const mockComposantListe = jest.fn()
→ Ensuite on va mocker l’appel du composant enfant avec la méthode jest.mock()
et le remplacer par une implémentation :
jest.mock("../ComposantListe/ComposantListe", () => ())
A noter qu’il faut donner en premier argument de jest.mock()
non pas le composant enfant directement, mais le chemin d’import.
→ Pour savoir si le mock du composant a été appelé et si il est présent dans le DOM, il est possible de personnaliser l’implémentation de la façon suivante :
const mockComposantListe = jest.fn();
jest.mock("../ComposantListe/ComposantListe", () => (props) => {
mockComposantListe(props);
return <mock-liste role="composant-liste" />
})
De cette façon, nous faisons en sorte de :
- Transmettre les props donnés au composant enfant à la fonction simulée, ici
mockComposantListe
. Les fonctions simulées gardent en mémoire quand elles ont été appelées et avec quoi. Ainsi, cela nous permettra de pouvoir faire des assertions sur les props qui sont passés au composant enfant. - Remplacer le composant enfant dans le DOM par une balise précise, facilement identifiable, ici
<mock-liste role="composant-liste" />
. Le rôle donné à l’élément est facultatif, mais permettra de facilement utiliser la méthodegetByRole()
de Testing-Library pour le retrouver.
Maintenant dans notre test, pour identifier que le composant enfant à bien été appelé (et mocké), nous avons deux solutions :
- Faire une assertion pour vérifier que l’élément de remplacement est bien présent dans le DOM. Exemple :
expect(screen.getAllByRole('composant-liste').toHaveLength(1)
- Faire une assertion sur la fonction simulée et voir combien de fois elle à été appelée :
expect(mockComposantListe).toHaveBeenCalledTimes(1);
En plus de cela, on peut également vérifier les props qui ont été donnés au composant-enfant et transmis à la fonction simulée. Pour cela, jest.fn()
nous donne la propriété mock.calls
qui permet de récupérer toutes les fois où la fonction simulée a été appelée et avoir quels arguments. Pour récupérer les props de mockComposantListe
, on peut alors utiliser :
mockComposantListe.mock.calls[0][0]
où le premier []
indique quels appels de la fonction simulée on souhaite récupérer (ici [0]
pour le premier appel) et où le second []
permet d’accéder aux différents arguments passés à la fonction. Dans notre cas, il n’y a qu’un seul argument contenant toutes les props
au format suivant :
{
titre : "titre de la liste",
}
Pour faire une assertion sur le texte passé à la props titre
, on peut alors écrire :
expect(mockComposantListe.mock.calls[0][0].titre).toBe('titre de la liste');
Ce qui donne le test complet suivant :
import { render } from "@testing-library/react"
import ComposantPrincipal from "./ComposantPrincipal";
const mockComposantListe = jest.fn();
jest.mock("../ComposantListe/ComposantListe", () => (props) => {
mockComposantListe(props);
return <mock-liste role="composant-liste" />
})
describe("<ComposantPrincipal />", () => {
test("composant enfant", () =>{
render(<ComposantPrincipal />);
expect(mockComposantListe).toHaveBeenCalledTimes(1);
expect(mockComposantListe.mock.calls[0][0].titre).toBe('titre de la liste');
})
})
Ainsi avec cette méthode il est possible de rendre les tests vraiment unitaires et d’avoir la possibilité de vérifier les données qui sont transmises aux composants enfants mockés.
Obtenir la couverture des tests
Avant de conclure cet article, il nous reste un dernier aspect important à voir : comment obtenir la couverture de nos tests.
La couverture des tests permet de savoir :
- Quelles parties des composants n’ont pas été couvertes par un test
- Identifier les composants qui n’ont pas encore de tests unitaires
- Quel est le pourcentage de l’application qui est testé
Jest nous permet facilement d’obtenir la couverture de test. Pour commencer, on peut ajouter dans le package.json une commande spécifique pour lancer Jest avec propriété --coverage=true
sur l’ensemble des tests existants.
Voici un exemple :
Ajouter ou compléter le fichier jest.config.json avec les propriétés suivantes :
//jest.config.json
{
testRegex: "/*.test.js$",
coverageDirectory: "coverage", // à personnaliser
collectCoverageFrom :["./src/**", "!./src/**/*.stories.*",],
coverageThreshold: {
global: {
branches: 60, // seuil à personnaliser
functions: 60, // seuil à personnaliser
lines: 60, // seuil à personnaliser
statements: 60 // seuil à personnaliser
}
},
}
testRegex
donne la regex pour identifier les fichiers de testcoverageDirectory
indique le répertoire où sera stocker le rapport de couverture au format HTMLcollectCoverageFrom
précise les fichiers à prendre en compte pour calculer la couverture et lesquels il faut exclure (avec!
devant). Dans l’exemple ci-dessus on calcule la couverture sur tous les fichiers contenus dans le dossiersrc
en excluant ceux ayant dans leur l’extension.stories.
.coverageThreshold
Permet de préciser en pourcentage à partir de quels seuils on considère la couverture acceptable.
L’ensemble des configurations possibles de Jest est détaillé sur https://jestjs.io/fr/docs/configuration.
Avec le package.json présent plus haut, on peut générer la couverture des tests avec la commande npm run test:coverage
. Dans l’arborescence du projet, plusieurs fichiers sont générés dans le répertoire indiqué dans coverageDirecory
. Pour visualiser la couverture, il faut ouvrir dans un navigateur le fichier index.html qui a été généré au plus haut niveau, dans notre cas /coverage/lcov-report/index.html
Le fichier ouvert permet d’afficher une page de ce type, avec le pourcentage de couverture global ainsi que la couverture de chacun des fichiers de l’application :
Vous pouvez en plus cliquer sur l’un des composants pour voir sa propre couverture en détails et voir les zones non couvertes :
Il est très compliqué d’avoir une couverture de tests à 100% sur toute une application, mais plus on aura un pourcentage haut de couverture, plus l’application pourra être considérée comme robuste.
Conclusion
Nous avons vu dans cet article les bases pour pouvoir faire des tests avec Jest et Testing-Library et comment les rendre unitaires.
Pour aller plus loin, un second article suivra pour présenter toutes les astuces qui permettent de plus facilement écrire les tests pour les composants faisant appel à Redux, à react-router ou bien encore ayant des hooks personnalisés.