Après avoir abordé les tests unitaires, et d’intégration (dans le cadre d’un backend Express), nous allons aborder d’autres concepts et pratiques, pour compléter la boîte à outils des développeur·euse·s d’applications web full-stack.
Les tests frontend seront assez spécifiques à React, mais la bibliothèque utilisée (Testing Library) offre des variantes pour d’autres frameworks, et pour du Vanilla JS. Les compétences acquises seront donc transférables à d’autres contextes.
Les tests end-to-end utiliseront l’outil Webdriver.io, écrit en JavaScript/TypeScript. D’autres outils existent y compris dans l’écosystème JavaScript, et bien sûr pour d’autres stacks.
Un dépôt vous est fourni avec un backend et frontend minimalistes.
Vous allez le forker et le cloner.
Voici son URL : https://gitlab.com/bhubert/express-react-testing-monorepo
Créez une branche dès que vous aurez cloné le
dépôt :
git checkout -b tests-frontend
.
❗️ IMPORTANT ❗️
- Il vous est demandé de committer et pousser votre travail au fur et à mesure de votre avancée. Cela fera partie de l’évaluation.
- Dès que vous aurez forké le dépôt, vous pourrez m’envoyer son URL par mail.
- Dans le README.md n’hésitez pas à préciser vos nom et prénom (s’ils ne sont pas clairement suggérés par votre username GitLab).
Les points suivants ne sont pas spécifiques : les objectifs restent les mêmes que pour les tests d’une façon générale.
Lors de la première session dédiée aux tests, nous avons utilisé Jest, dans le contexte de tests unitaires de fonctions et de classes, et de tests d’intégration d’une application backend (Express).
Nous allons utiliser un autre outil, qui a émergé plus récemment, comme alternative à Jest : Vitest.
Pourquoi l’utiliser plutôt que Jest ? Voici quelques éléments de réponse.
Lors de la dernière session, nous avons utilisé Jest, à la fois comme test runner et comme bibliothèque d’assertions, là où, avant que Jest ne devienne prédominant, on aurait pu utiliser des outils distincts, tels que Mocha (test runner) et Chai (bibliothèque d’assertions).
Ici c’est Vitest qui va remplir ce rôle de test runner et bibliothèque d’assertions.
Le rôle de React Testing Library (RTL) va être d’interagir
avec nos composants React et avec le “DOM” simulé via la
bibliothèque jsdom
.
RTL va nous permettre, entre autres :
Les outils ont déjà été configurés, afin de pouvoir nous concentrer sur l’écriture des tests proprement dite.
Ici, nous avons choisi de placer les tests “côte à côte” avec leur System Under Test respectif - c’est à dire les composants testés.
Les fichiers .test.tsx
sont donc au même
niveau de l’arborescence que les .tsx
associés.
C’est une organisation différente de celle que nous avions
adoptée pour le backend. Notez que nous aurions pu faire
pareil du côté backend. Mais il aurait fallu par exemple
exclure les fichiers .test.ts
du build
TypeScript. Ici, l’outil de build s’en charge, et ne va
packager dans le “bundle” JavaScript final que les
composants.
App
Regardez le fichier frontend/src/App.test.tsx
,
qui contient un test pour le composant App
.
N’hésitez pas à regarder le code source de
App.tsx
en même temps.
// src/App.test.tsx
import { render, screen } from "@testing-library/react";
import App from "./App";
it("renders without crashing", () => {
// Rendu du composant App dans le DOM virtuel
render(<App />);
// Récupération de l'élément titre par son texte
const titleElement = screen.getByText(/Vite \+ React/i);
// Assertions pour vérifier la présence du titre et sa classe CSS
expect(titleElement).toBeInTheDocument();
expect(titleElement).toHaveClass("App-title");
; })
Ce fichier minimaliste nous permet d’aborder quelques points récurrents que nous retrouverons tout au long de nos tests.
les imports :
render
et screen
depuis
@testing-library/react
(RTL)dans le test :
screen.getByText()
)Title
Title.tsx
pour y écrire un composant
Title
,Title.test.tsx
pour écrire les tests.Le composant Title
aura la “spécification
suivante”. Ne l’écrivez pas maintenant car
vous devrez d’abord écrire le test :
title
qui vaudra
Default Title
par défaut,h1
ayant pour contenu la prop
title
passéeÉcrivez le test en vous inspirant de celui de
App.test.tsx
, avec ces variantes
:
Utilisez screen.getByRole('heading')
pour
récupérer le titre, plutôt que
screen.getByText()
. Cette
page sur MDN donne une référence de toutes les valeurs
possibles pour les rôles ; un rôle est attribué par défaut à
certaines balises comme les h1
à h6
(rôle heading
) ou button
(rôle
button
) ; on peut également attribuer un rôle
explicitement avec l’attribut role
, tant que la
valeur associée est listée parmi les valeurs possibles dans la
documentation mise en lien.
Vérifiez que le contenu de ce titre est bien celui
attendu avec le “matcher” toHaveTextContent()
,
qui prend une string comme argument — doc
de toHaveTextContent()
Écrivez deux tests :
title
est passée
(le titre affiché doit donc être la valeur passée),Indices
Si vous ne savez pas comment attribuer une “prop” par défaut :
ToggleButton
Dans cet exercice, nous allons créer un composant
ToggleButton
qui change de classe CSS lorsqu’il
est cliqué, affectant ainsi sa couleur de fond. Nous testerons
également ce comportement avec React Testing Library.
ToggleButton.tsx
: pour le composant
ToggleButton
.ToggleButton.test.tsx
: pour les tests de
ToggleButton
.ToggleButton.css
: pour définir les classes
CSS red
, blue
et
gray
.Dans ToggleButton.css
, définissez les classes
pour les couleurs de fond :
.red {
background-color: red;
}
.blue {
background-color: blue;
}
/* Pour la partie optionnelle de l'exercice */
.gray {
background-color: gray;
}
Commencez par écrire un test pour vérifier le rendu initial
du bouton avec la classe red
et le texte “Change
to blue”.
Dans un premier temps, ce test va échouer et c’est normal, car vous n’aurez pas encore écrit le System Under Test, c’est-à-dire le composant à tester. C’est l’essence du TDD ou Test-Driven Development : écrire un test qui échoue, puis écrire le test pour le faire passer ! Puis répéter au fur et à mesure qu’on ajoute des “exigences” à satisfaire.
ToggleButton.test.tsx
:
import { render, screen } from "@testing-library/react";
import ToggleButton from "./ToggleButton";
describe("ToggleButton", () => {
it('renders with initial class "red" and text "Change to blue"', () => {
// Votre code ici pour rendre le composant et vérifier la classe et le texte initiaux
});
});
Vous pouvez utiliser encore la query
screen.getByRole
pour récupérer le bouton de
cette façon :
const buttonElement = screen.getByRole("button", { name: /change to blue/i });
Elle permet de récupérer le bouton :
button
)i
pour
case-insensitive), ce qui permet de rendre la requête
plus précise (dans le cas où par exemple on aurait plusieurs
boutons sur la page).Utilisez le matcher toHaveClass()
prenant une
string en argument, pour vérifier que le bouton a la
classe red
.
Écrivez ensuite le code de ToggleButton
. Dans
un premier temps, vous pouvez juste renvoyer un
button
avec la classe red
et le
texte Change to blue
.
Il va vous falloir ajouter un test pour simuler un clic sur le bouton et vérifier que la classe et le texte changent comme attendu.
Vous aurez besoin d’importer, en plus de
screen
et render
, la méthode
fireEvent
de @testing-library/react
.
Voici un exemple de son utilisation - adaptez
au test écrit ci-dessus au-lieu de simplement copier-coller
!
import { render, screen, fireEvent } from "@testing-library/react";
import MyComponent from "./MyComponent";
it("test user interaction", () => {
render(<MyComponent />);
// récupérer l'élément
const buttonElement = screen.getByRole("button");
// simuler un clic sur le bouton
.click(buttonElement);
fireEvent
// effectuer vos assertions
; })
À vous de jouer : dans votre fichier de tests, ajoutez un deuxième test pour effectuer le clic sur le bouton, et vérifier la nouvelle classe et le nouveau texte.
Il est temps d’ajouter du “dynamisme” au bouton, et d’implémenter la logique de changement de couleur et de texte.
useState
pour
stocker la couleur (red
ou blue
)
avec comme valeur initiale red
.useState
, déclarer une
variable nextColor
qui dépendra du state déclaré
avec useState
. Si la couleur dans le state vaut
red
, alors nextColor
vaudra
blue
, et vice-versa.button
avec comme attribut
className
la couleur stockée dans le state, et un
texte qui utilisera nextColor
pour afficher
“Change to blue” ou “Change to red”.Pour aller plus loin, ajoutez une checkbox qui contrôle
l’état “disabled” du bouton. Lorsque la checkbox est cochée,
le bouton doit être désactivé et avoir la classe
gray
.
Indications pour le test :
ToggleButton
screen.getByRole('checkbox')
.fireEvent
pour simuler le clic sur
la checkbox.toBeDisabled()
) et obtient la classe
gray
lorsque la checkbox est cochée.toBeChecked()
).Suivant le temps que vous pouvez y consacrer, écrivez
éventuellement un test supplémentaire avec deux
clics sur la checkbox pour vérifier que la checkbox
revient à son état non-coché d’origine
(.not.toBeChecked()
) et que le bouton est à
nouveau actif (toBeEnabled()
)
Après avoir écrit les tests, modifiez encore le composant
ToggleButton
pour satisfaire les nouvelles
spécifications et passer les tests.
Cet exercice vous aidera à comprendre comment tester les
interactions utilisateur et les changements de classe CSS dans
les composants React. Vous avez appris à utiliser différentes
queries
et matchers
de RTL pour
récupérer des éléments et vérifier leurs propriétés.
L’exercice optionnel vous offre une opportunité supplémentaire
de pratiquer les tests d’interactions plus complexes et les
changements d’état.
Le composant SignupForm
représente une étape
avancée de notre exploration des tests React. Il combine
plusieurs aspects du développement frontend, notamment la
gestion des états, les validations des champs de formulaire,
et l’intégration avec des services backend pour les
vérifications de disponibilité. Cette section vous guidera à
travers la construction et le test de ce composant de manière
incrémentale.
Avant de plonger dans SignupForm
, nous
commencerons par un composant plus simple :
AlertBox
. Ce composant affichera des messages de
feedback à l’utilisateur, tels que des erreurs de validation
ou des confirmations de succès.
Spécifications :
message
: une chaîne de caractères contenant
le message à afficher.status
: une chaîne de caractères pouvant
être 'success'
ou 'failure'
,
déterminant la couleur du message (green
pour
succès, red
pour échec).div
avec le rôle
alert
et afficher le message dans la couleur
correspondante au status
.Écrivez le test en premier en vous basant sur ce squelette de test pour AlertBox :
import { render, screen } from "@testing-library/react";
import AlertBox from "./AlertBox";
describe("AlertBox", () => {
it("should display a success message with green color", () => {
// Test implementation...
;
})
it("should display a failure message with red color", () => {
// Test implementation...
;
}); })
Avec AlertBox
en place, nous pouvons commencer
à travailler sur SignupForm
. Ce formulaire
comprendra des champs pour username
,
email
, et password
, chacun avec ses
propres règles de validation.
Squelette de SignupForm :
import React, { useState } from "react";
import AlertBox from "./AlertBox";
interface ValidationOutcome {
isValid: boolean;
message: string;
}
/**
* Vérifie qu'un username est valide : commence par une lettre, suivie de lettres et nombres
*/
function checkUsername(username: string): ValidationOutcome {
// à remplir
}
/**
* Vérifie qu'un email est valide (possibilité d'utiliser une lib externe ou une regex)
*/
function checkEmail(email: string): ValidationOutcome {
// à remplir
}
/**
* Vérifie qu'un mot de passe est valide :
* - au moins 8 caractères,
* - éventuellement vérifier présence de minuscules, majuscules ET nombres
*/
function checkPassword(password: string): ValidationOutcome {
// à remplir
}
export default function SignupForm() {
// valeur du state pour username
const [username, setUsername] = useState("");
// informations de validation pour username
// si le formulaire n'a pas encore été soumis => null
// s'il a été soumis, on va stocker un objet contenant un booléen `isValid`,
// et une string `message` (vide si `isValid` vaut true, avec la raison du problème sinon)
const [usernameValidation, setUsernameValidation] =
useState<ValidationOutcome | null>(null);
const [email, setEmail] = useState("");
const [emailValidation, setEmailValidation] =
useState<ValidationOutcome | null>(null);
const [password, setPassword] = useState("");
const [passwordValidation, setPasswordValidation] =
useState<ValidationOutcome | null>(null);
// sera utilisé à la toute fin, pour stocker le résultat
// de la soumission du formulaire
const [submissionOutcome, setSubmissionOutcome] = useState<{
message: string;
status: "success" | "failure";
} | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
// Validation et logique de soumission du formulaire
// C'est notamment ici qu'on va, pour chaque champ:
// 1. appeler le "check*" correspondant : checkUsername, etc.
// 2. stocker le résultat dans une variable locale (checkUsernameOutcome par ex.)
// 3. stocker la valeur de cette variable dans l'état correspondant (`setUsernameValidation(checkUsernameOutcome)`)
// Si l'une des validations a échoué on n'ira pas plus loin
// Sinon on effectuera une requête AJAX vers /api/auth/signup
// Idéalement utiliser axios !
};
return (
<form onSubmit={handleSubmit}>
{/* Input fields for username, email, and password */}
<button type="submit">Sign Up</button>
</form>
);
}
Votre premier test consistera à vérifier que la soumission
d’un formulaire vide déclenche des erreurs de validation pour
chaque champ. Utilisez des AlertBox
pour afficher
ces erreurs.
Indications pour le test :
Rendez le SignupForm
et simulez une
soumission.
Utilisez getAllByRole('alert')
pour
récupérer toutes les instances
d’AlertBox
.
Assurez-vous que chaque erreur de validation attendue est affichée. Par exemple :
Invalid username
Invalid email
Invalid password: too short (< 8 characters), missing uppercase letters, missing lowercase letters, missing numbers
Implémentez la logique de validation dans
SignupForm
. Assurez-vous que les champs
username
, email
, et
password
respectent des critères spécifiques (par
exemple, format d’email valide, longueur minimum du mot de
passe, etc.). Affichez une AlertBox
avec un
message d’erreur approprié en cas de validation échouée.
Après avoir géré les validations côté client, vous aborderez la gestion des réponses serveur.
Testez le comportement de SignupForm
lorsque
le serveur renvoie une erreur (par exemple,
username
déjà utilisé) ou une confirmation de
succès.
On va vous proposer des indications pour les tests avancés, et deux méthodes différentes.
Mais il y a des notions fondamentales communes aux deux méthodes, que nous allons détailler.
L’utilisation d’axios
permet de simplifier la
gestion des requêtes AJAX, en fournissant une meilleure
gestion des erreurs, la conversion automatique des données
envoyées/reçues en JSON, etc.
Mais cela introduit dans notre composant une
dépendance. Le problème serait tout de même
quasiment identique, même si on utilisait l’API
fetch
du navigateur au lieu d’Axios.
Le problème est qu’on dépend maintenant d’une source de données externe à notre composant, et même à notre application React : le backend qui va recevoir la requête d’inscription.
On souhaite pouvoir tester nos composants - y compris les requêtes AJAX - sans avoir besoin de démarrer un serveur (ce qui peut s’avérer assez lourd).
Mocker Axios consiste à remplacer la véritable bibliothèque par un “double” offrant le même comportement.
Dans les tests :
import { vi, Mocked } from "vitest";
// autres imports omis
// Mocker le module axios
.mock("axios");
viconst mockedAxios = axios as Mocked<typeof axios>;
describe("tests with axios", () => {
beforeEach(() => {
// Réinitialiser les mocks avant chaque test
.post.mockClear();
mockedAxios;
})
it("test with axios", async () => {
// On simule la réponse { message: 'Welcome, user' } renvoyée par le serveur
.post.mockResolvedValue({ data: { message: "Welcome, user" } });
mockedAxios
// Rendu composant
render(<SignupForm />);
// Récupération éléments et interactions
// ...
// waitFor permet d'attendre l'apparition d'un élément
await waitFor(() => {
const alertElements = screen.queryAllByRole("alert");
expect(alertElements).toHaveLength(1);
// On retrouve le message qu'on a récupéré du serveur
expect(alertElements[0]).toHaveTextContent("Welcome, user");
;
});
}); })
msw
msw
pour intercepter et manipuler
les réponses du serveur.AlertBox
appropriées sont affichées.La mise en place msw
n’est pas si
compliquée mais peut se heurter à quelques obstacles
:
msw
, mais avec
l’ancienne version 1.x
.Aussi, voici quelques “raccourcis” pour vous permettre de démarrer.
Commencez par créer un fichier
src/msw/handlers.ts
avec ce contenu :
import { http, HttpResponse } from "msw";
const somePostHandler = async ({ request, params }) => {
// Permet de récupérer des données envoyées JSON en POST/PUT
const data = await request.json();
console.log("\n\n>>> data:", data);
// Possibilité de mettre en place une gestion d'erreur ici
// par exemple, si un champ requis dans data n'est pas présent, ou invalide,
// renvoyer une réponse avec un message d'erreur et un code de statut 4xx
return HttpResponse.json(
// 1er argument = les données à renvoyer. Ce serait l'équivalent de ce qui
// est passé à res.json() dans Express
: "Data received", success: true },
{ message// 2ème argument (optionnel) = (méta-)données supplémentaires.
// Ici un code de statut 201 est un code "succès" pour signifier qu'une ressource
// a bien été créée
: 201 }
{ status;
);
}
export const handlers = [http.post("/api/some-endpoint", somePostHandler)];
Créez un deuxième fichier, src/msw/server.ts
:
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Ce server
sera importé depuis les tests. Voici
la structure générale pour l’utiliser dans les tests :
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import MyComponent from "./MyComponent";
import { server } from "./msw/server";
describe("MyComponent", () => {
beforeAll(() => {
// Avant de démarrer cette suite de tests :
// met le faux serveur en écoute
.listen();
server;
})
afterEach(() => {
// Après chaque test : nettoyer tous les handlers
// et leurs données
.resetHandlers();
server;
})
afterAll(() => {
// À la fin de l'exécution de cette suite, fermer le faux serveur
.close();
server;
})
it("success case - all fields good and no duplicate", async () => {
render(<MyComponent />);
// Récupérer les éléments (par ex. champs input, boutons, etc.)
const myTextInput = screen.getByRole("textbox", {
: /label du champ input/i,
name;
})const submitButton = screen.getByRole("button", { name: /submit/i });
// ...
// Simuler des saisies clavier en envoyant des évènements "change"
.change(myTextInput, { target: { value: "someValue" } });
fireEvent
// Soumettre le formulaire => CENSÉ ÉMETTRE UNE REQUÊTE AJAX
.click(submitButton);
fireEvent
await waitFor(() => {
console.log("waitFor");
const successMessage = screen.getByRole("alert");
expect(successMessage).toHaveTextContent("My message");
;
});
}); })
Cette approche progressive vous permettra de construire et
de tester efficacement un composant SignupForm
robuste et réactif aux interactions utilisateur et aux
réponses serveur.
Lancer la commande à la racine du repo :
npm init wdio@latest .
Cela va lancer un questionnaire :
===============================
🤖 WDIO Configuration Wizard 🧙
===============================
? A project named "express-react-testing-monorepo" was detected at "/Users/benoit/IPI/express-react-testing-monorepo", correct? Yes
? What type of testing would you like to do? E2E Testing - of Web or Mobile Applications
? Where is your automation backend located? On my local machine
? Which environment you would like to automate? Web - web applications in the browser
? With which browser should we start? Chrome
? Which framework do you want to use? Mocha (https://mochajs.org/)
? Do you want to use a compiler? TypeScript (https://www.typescriptlang.org/)
? Do you want WebdriverIO to autogenerate some test files? Yes
? What should be the location of your spec files? /Users/benoit/IPI/express-react-testing-monorepo/test/specs/**/*.ts
? Do you want to use page objects (https://martinfowler.com/bliki/PageObject.html)? No
? Which reporter do you want to use? spec
? Do you want to add a plugin to your test setup? wait-for: utilities that provide functionalities to wait for certain conditions till a defined task is complete.
> https://www.npmjs.com/package/wdio-wait-for
? Would you like to include Visual Testing to your setup? For more information see https://webdriver.io/docs/visual-testing! No
? Do you want to add a service to your test setup?
? Do you want me to run `npm install` Yes
Pour lancer les tests : npm run wdio
Lancer npm run dev
sous
frontend
Modifier test/specs/test.e2e.ts
de façon à
interagir avec le formulaire
Écrire deux cas de test :
Écrire un serveur Express minimaliste dans
backend/src/index.js
avec juste un endpoint
/api/auth/signup
.
Email johndoe@example.com already taken
que vous
pouvez afficher dans l’interface, afin de la tester avec
wdio.