Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 923 minIntermédiaire

Tester le front : React Testing Library et Playwright (E2E)

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • tester un composant React avec Testing Library ;
  • écrire un test de bout en bout avec Playwright ;
  • choisir des localisateurs robustes et des assertions qui attendent ;
  • diagnostiquer et corriger un test instable ;
  • vérifier automatiquement l'accessibilité d'une page.

4 exercices corrigés

Un test de bout en bout (end-to-end, E2E) pilote un vrai navigateur comme le ferait un utilisateur : il ouvre la page, remplit le formulaire, clique et vérifie ce qui s'affiche, à travers toutes les couches de l'application. Playwright, l'outil de Microsoft, est la référence en 2026 pour les écrire. Entre le test unitaire et le test de bout en bout, le test de composant vérifie un composant React seul, dans un navigateur simulé, avec React Testing Library. Ce chapitre teste le formulaire de réservation de Prêt'Matos des deux façons.

Deux façons de tester une interface

Test de composant (Vitest + Testing Library)Test de bout en bout (Playwright)
Ce qui tourneUn composant React, dans un DOM simulé (jsdom)Toute l'application : front compilé, API, base de test
Le navigateurAucun : jsdom imite le DOM en JavaScriptUn vrai Chromium, Firefox ou WebKit
L'APIRemplacée par une doublure (vi.fn())La vraie, sur un serveur de test
DuréeQuelques dizaines de millisecondesQuelques centaines de millisecondes à quelques secondes
Ce qu'il prouveLe composant affiche et réagit correctementLe parcours marche de bout en bout, pour de vrai
CombienUn par comportement du composantQuelques-uns, pour les parcours essentiels

Les deux reposent sur la même philosophie, résumée par la devise de Testing Library : plus tes tests ressemblent à la façon dont le logiciel est utilisé, plus ils te donnent confiance. On cherche donc les éléments comme un utilisateur : par leur rôle (un bouton), leur étiquette (« Matériel »), leur texte. Jamais par une classe CSS ou un identifiant technique, que l'utilisateur ne voit pas et que le prochain refactoring changera.

Le composant à tester

jsx
// front/FormulaireReservation.jsx
import { useState } from 'react';

export default function FormulaireReservation({ reserver }) {
  const [message, setMessage] = useState(null);

  async function envoyer(formData) {
    const resultat = await reserver({
      materiel: formData.get('materiel'),
      debut: formData.get('debut'),
      fin: formData.get('fin'),
    });
    setMessage(resultat.ok ? 'Réservation enregistrée.' : resultat.erreur);
  }

  return (
    <form action={envoyer}>
      <label>
        Matériel
        <select name="materiel" defaultValue="PC-07">
          <option value="PC-07">PC-07 (portable)</option>
          <option value="VP-02">VP-02 (vidéoprojecteur)</option>
        </select>
      </label>
      <label>
        Du
        <input type="date" name="debut" required />
      </label>
      <label>
        Au
        <input type="date" name="fin" required />
      </label>
      <button type="submit">Réserver</button>
      {message && <p role="status">{message}</p>}
    </form>
  );
}

Le composant reçoit la fonction reserver en propriété : dans l'application, c'est celle de front/api.js qui appelle l'API (chapitre 7) ; dans le test, une doublure. Il utilise une action de formulaire, une nouveauté de React 19 : <form action={envoyer}> appelle envoyer avec les données du formulaire (FormData) à la soumission. Le message est affiché dans un paragraphe de rôle status, que les lecteurs d'écran annoncent : c'est bon pour l'accessibilité, et c'est aussi ce qui permet au test de le trouver par son rôle. Le cours React détaille les composants, les formulaires et leur accessibilité.

Tester le composant avec React Testing Library

bash
npm install --save-dev @testing-library/react @testing-library/dom @testing-library/user-event jsdom
jsx
// front/FormulaireReservation.test.jsx
// @vitest-environment jsdom
import { test, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormulaireReservation from './FormulaireReservation.jsx';

afterEach(cleanup);

test('envoie la demande saisie et affiche la confirmation', async () => {
  const reserver = vi.fn().mockResolvedValue({ ok: true });
  const utilisateur = userEvent.setup();
  render(<FormulaireReservation reserver={reserver} />);

  await utilisateur.selectOptions(screen.getByLabelText('Matériel'), 'VP-02');
  await utilisateur.type(screen.getByLabelText('Du'), '2027-03-15');
  await utilisateur.type(screen.getByLabelText('Au'), '2027-03-18');
  await utilisateur.click(screen.getByRole('button', { name: 'Réserver' }));

  expect(reserver).toHaveBeenCalledWith({ materiel: 'VP-02', debut: '2027-03-15', fin: '2027-03-18' });
  expect((await screen.findByRole('status')).textContent).toBe('Réservation enregistrée.');
});

test("affiche le motif du refus renvoyé par l'API", async () => {
  const reserver = vi.fn().mockResolvedValue({ ok: false, erreur: 'PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.' });
  const utilisateur = userEvent.setup();
  render(<FormulaireReservation reserver={reserver} />);

  await utilisateur.type(screen.getByLabelText('Du'), '2027-03-12');
  await utilisateur.type(screen.getByLabelText('Au'), '2027-03-18');
  await utilisateur.click(screen.getByRole('button', { name: 'Réserver' }));

  expect((await screen.findByRole('status')).textContent).toBe('PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.');
});
ÉlémentRôle
// @vitest-environment jsdomCe fichier tourne dans un DOM simulé. On peut aussi régler environment: 'jsdom' dans la configuration de Vitest
render(<… />)Affiche le composant dans le DOM simulé
screen.getByLabelText('Du')Trouve le champ par son étiquette, comme un utilisateur ; échoue s'il n'existe pas
screen.getByRole('button', { name: 'Réserver' })Trouve le bouton par son rôle et son nom accessible
userEvent.setup(), puis type, click, selectOptionsSimule de vraies interactions, touche par touche, avec les événements qu'un navigateur produirait
await screen.findByRole('status')Attend que l'élément apparaisse : le message s'affiche après la promesse de reserver
afterEach(cleanup)Retire le composant du DOM après chaque test

Le test vérifie aussi que le composant transmet les bonnes données : toHaveBeenCalledWith sur la doublure prouve que le formulaire envoie VP-02 et les dates saisies, au format AAAA-MM-JJ des champs type="date".

Playwright : installer et configurer

Pour un nouveau projet, npm init playwright@latest pose quelques questions, crée la configuration et un exemple, et télécharge les navigateurs. Dans un projet existant :

bash
npm install --save-dev @playwright/test
npx playwright install chromium

Playwright télécharge ses propres versions de Chromium, Firefox et WebKit (le moteur de Safari) : le test tourne dans le même navigateur sur tous les postes et dans la CI. La configuration de Prêt'Matos :

js
// playwright.config.js
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  retries: process.env.CI ? 2 : 0,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:3100',
    trace: 'on-first-retry',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'node src/serveur-recette.js',
    url: 'http://localhost:3100',
    reuseExistingServer: !process.env.CI,
  },
});
RéglageEffet
testDir: './e2e'Les tests de bout en bout vivent à part, dans e2e/, pour ne pas être lancés par Vitest
webServerPlaywright démarre lui-même l'application avant les tests et l'arrête à la fin. Ici, un serveur de recette qui sert l'API et le front compilé, sur une base de démonstration
baseURLLes tests écrivent page.goto('/') au lieu de l'adresse complète
trace: 'on-first-retry'Quand un test échoue puis est relancé, Playwright enregistre une trace : captures, DOM, requêtes réseau, à chaque étape
retriesDans la CI, un test qui échoue est relancé deux fois avant d'être déclaré en échec ; en local, jamais, pour voir les problèmes tout de suite
projectsLes navigateurs à tester. On peut ajouter devices['Desktop Firefox'], devices['Desktop Safari'] ou un téléphone, devices['Pixel 7']

Le serveur de recette assemble l'application avec le dépôt en mémoire et le jeu de données du chapitre 8 :

js
// src/serveur-recette.js : l'API et le front compilé, sur une base de démonstration
import express from 'express';
import { creerApp } from './app.js';
import { creerDepotMemoire } from './depot-memoire.js';
import { AUJOURDHUI, UTILISATEURS, jeuDeTest } from './jeu-de-test.js';

const app = creerApp({
  depot: creerDepotMemoire(jeuDeTest()),
  authentifier: async (jeton) => UTILISATEURS[jeton] ?? null,
  aujourdhui: () => AUJOURDHUI,
});
app.use(express.static('dist'));
app.listen(3100, () => console.log('Prêt\'Matos (recette) sur http://localhost:3100'));

Le front est compilé au préalable par Vite (npx vite build, qui produit le dossier dist/). Dans ce serveur de démonstration, la page d'accueil range un jeton fictif dans le navigateur, pour éviter l'écran de connexion. Dans un vrai projet, le test commence par se connecter, ou réutilise une session enregistrée une fois pour toutes (storage state).

Le premier test de bout en bout

js
// e2e/reservation.spec.js
import { test, expect } from '@playwright/test';

test.describe('Réserver un matériel', () => {
  test('Julie réserve le vidéoprojecteur libre', async ({ page }) => {
    await page.goto('/');

    await page.getByLabel('Matériel').selectOption('VP-02');
    await page.getByLabel('Du').fill('2027-03-15');
    await page.getByLabel('Au').fill('2027-03-18');
    await page.getByRole('button', { name: 'Réserver' }).click();

    await expect(page.getByRole('status')).toHaveText('Réservation enregistrée.');
  });

  test('Julie voit pourquoi PC-07 est refusé', async ({ page }) => {
    await page.goto('/');

    await page.getByLabel('Matériel').selectOption('PC-07');
    await page.getByLabel('Du').fill('2027-03-12');
    await page.getByLabel('Au').fill('2027-03-18');
    await page.getByRole('button', { name: 'Réserver' }).click();

    await expect(page.getByRole('status')).toHaveText('PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.');
  });
});
text
$ npx playwright test

Running 2 tests using 1 worker

  ✓  1 [chromium] › e2e/reservation.spec.js:5:3 › Réserver un matériel › Julie réserve le vidéoprojecteur libre (708ms)
  ✓  2 [chromium] › e2e/reservation.spec.js:16:3 › Réserver un matériel › Julie voit pourquoi PC-07 est refusé (256ms)

  2 passed (3.9s)

Chaque test reçoit une page neuve, dans un contexte de navigateur isolé (comme une fenêtre de navigation privée) : pas de cookie ni de stockage partagé entre deux tests. Le second test traverse vraiment toutes les couches : le formulaire React envoie la requête, l'API Express applique la règle, le dépôt fournit la réservation existante, et le message de l'API s'affiche dans la page.

Les localisateurs

Un localisateur (locator) décrit comment trouver un élément. Playwright recommande les mêmes priorités que Testing Library :

LocalisateurTrouve…Exemple
page.getByRole(rôle, { name })un élément par son rôle d'accessibilité et son nomgetByRole('button', { name: 'Réserver' }), getByRole('heading', { name: 'Mes réservations' })
page.getByLabel(texte)un champ par son étiquettegetByLabel('Du')
page.getByText(texte)un élément par le texte qu'il contientgetByText('Réservation enregistrée.')
page.getByPlaceholder(texte)un champ par son texte indicatifgetByPlaceholder('Rechercher un matériel')
page.getByTestId(id)un élément par son attribut data-testiden dernier recours
page.locator('css')un sélecteur CSS ou XPathà éviter : fragile au moindre changement de structure

Lancer, observer, déboguer

CommandeEffet
npx playwright testLance tous les tests, navigateur invisible (headless)
npx playwright test --headedMontre le navigateur pendant le test
npx playwright test --uiOuvre l'interface de Playwright : liste des tests, exécution pas à pas, captures à chaque étape, mode surveillance
npx playwright test --debugExécute pas à pas avec l'inspecteur
npx playwright codegen http://localhost:3100Enregistre tes clics dans un navigateur et écrit le code du test avec les bons localisateurs
npx playwright show-reportOuvre le rapport HTML de la dernière exécution
npx playwright show-trace chemin/trace.zipRejoue la trace d'un test en échec, étape par étape

Le générateur de code (codegen) est un bon point de départ : il propose des localisateurs getByRole et getByLabel. Relis toujours ce qu'il produit, ajoute les assertions qu'il ne devine pas, et range les étapes répétées dans des fonctions.

Les tests instables

Un test instable (flaky test) passe ou échoue au hasard, sans que le code change. Les tests de bout en bout y sont les plus exposés, parce qu'ils dépendent de tout : le réseau, la vitesse de la machine, l'état des données.

CauseExemple dans Prêt'MatosRemède
Données partagées entre testsLe premier test réserve VP-02 du 15 au 18 mars sur le serveur de recette. S'il est relancé, la réservation existe déjà : l'API répond 409 et le test échoueRemettre les données dans un état connu avant chaque test (une route de réinitialisation réservée à l'environnement de test, un script SQL), ou donner à chaque test ses propres données (un matériel, des dates)
Attente fixewaitForTimeout(1000) avant de lire le messageAssertions web-first : await expect(…).toHaveText(…)
Ordre des testsUn test suppose qu'un autre a créé la réservationChaque test prépare ce dont il a besoin
Date réelleLe serveur utilise la vraie date du jour : la réservation du 15 mars 2027 sera « dans le passé » l'année suivanteUne date fixe pour l'environnement de test (AUJOURDHUI du jeu de données)
Animation, chargementLe clic part pendant une transitionL'attente automatique de Playwright la gère ; sinon, attendre un état visible précis
Service externeLa connexion passe par un fournisseur d'identité en ligneUne session préparée, ou un fournisseur de test

Les relances automatiques (retries) de la CI masquent l'instabilité sans la corriger. Un test instable doit être corrigé tout de suite, ou mis en quarantaine avec un ticket : une équipe qui s'habitue à relancer « jusqu'à ce que ça passe » finit par ignorer les vrais échecs. Le cours Intégration continue revient sur ce point (les tests instables dans la CI).

Vérifier l'accessibilité automatiquement

Le paquet @axe-core/playwright analyse la page avec axe, un moteur qui détecte les défauts d'accessibilité vérifiables automatiquement : image sans texte alternatif, champ sans étiquette, contraste insuffisant, structure de page incomplète.

bash
npm install --save-dev @axe-core/playwright
js
// e2e/accessibilite.spec.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test("la page de réservation n'a pas de défaut d'accessibilité détectable", async ({ page }) => {
  await page.goto('/');

  const resultats = await new AxeBuilder({ page }).analyze();

  expect(resultats.violations).toEqual([]);
});

Lancé sur la première version de la page de Prêt'Matos, ce test a échoué avec deux règles : landmark-one-main (« le document doit avoir un repère principal ») et region (« tout le contenu doit être contenu dans des repères »). Le formulaire n'était pas dans un élément <main>, que les lecteurs d'écran utilisent pour aller directement au contenu. Une balise ajoutée, et le test est passé. Un outil automatique ne détecte qu'une partie des défauts d'accessibilité ; il ne remplace pas un essai au clavier et avec un lecteur d'écran, mais il évite les régressions grossières.

Playwright en C# et ailleurs

Playwright existe aussi pour .NET, Python et Java, avec les mêmes concepts et les mêmes localisateurs. En C#, le paquet Microsoft.Playwright s'utilise avec NUnit ou MSTest, et le SDK .NET 10 fournit des modèles prêts à l'emploi : dotnet new nunit-playwright et dotnet new mstest-playwright. Pour une application PHP sans framework, rien n'empêche d'écrire les tests de bout en bout en JavaScript avec Playwright : il pilote un navigateur, peu importe le langage du serveur.

Exercices

Exercice 1 sur 4 Écrire un test de bout en bout Réussi À revoir

Écris avec Playwright le test du scénario suivant : Julie demande VP-02 du 15 au 30 mars ; la page doit afficher le refus « Un prêt dure au plus 15 jours. ».

Voir la réponseMasquer la réponse de l'exercice 1
js
import { test, expect } from '@playwright/test';

test('Julie voit le refus pour un prêt de 16 jours', async ({ page }) => {
  await page.goto('/');

  await page.getByLabel('Matériel').selectOption('VP-02');
  await page.getByLabel('Du').fill('2027-03-15');
  await page.getByLabel('Au').fill('2027-03-30');
  await page.getByRole('button', { name: 'Réserver' }).click();

  await expect(page.getByRole('status')).toHaveText('Un prêt dure au plus 15 jours.');
});

Ce test passe, mais demande-toi s'il est utile : la règle des 15 jours est déjà testée unitairement, et l'affichage d'un motif de refus est déjà couvert par le test de PC-07. Il n'apporte presque rien de plus, et coûte une seconde à chaque exécution. Dans la pyramide, on garde un test de bout en bout par parcours (une réservation acceptée, une refusée), pas un par règle.

Tu avais trouvé ?

Exercice 2 sur 4 Choisir le localisateur Réussi À revoir

Pour chaque élément, propose le meilleur localisateur Playwright.

  1. Le bouton « Annuler la réservation ».
  2. Le champ de saisie dont l'étiquette est « Adresse électronique ».
  3. Le titre de niveau 1 « Mes réservations ».
  4. Une icône de poubelle sans texte, dans une ligne de tableau.
Voir la réponseMasquer la réponse de l'exercice 2
  1. page.getByRole('button', { name: 'Annuler la réservation' }).
  2. page.getByLabel('Adresse électronique').
  3. page.getByRole('heading', { name: 'Mes réservations', level: 1 }).
  4. Une icône sans texte n'a pas de nom accessible : un lecteur d'écran annonce « bouton », sans plus. La bonne correction est dans le code : <button aria-label="Supprimer la réservation de PC-07">. On peut alors écrire page.getByRole('button', { name: 'Supprimer la réservation de PC-07' }). Utiliser getByTestId contournerait le problème au lieu de le corriger.

Tu avais trouvé ?

Exercice 3 sur 4 Diagnostiquer un test instable Réussi À revoir

Ce test passe sur le poste de Yanis et échoue une fois sur trois dans la CI, avec Expected: "Réservation enregistrée." Received: "".

js
test('réserve VP-02', async ({ page }) => {
  await page.goto('/');
  await page.getByLabel('Matériel').selectOption('VP-02');
  await page.getByLabel('Du').fill('2027-03-15');
  await page.getByLabel('Au').fill('2027-03-18');
  await page.getByRole('button', { name: 'Réserver' }).click();
  await page.waitForTimeout(500);
  expect(await page.getByRole('status').textContent()).toBe('Réservation enregistrée.');
});

Explique la cause et corrige.

Voir la réponseMasquer la réponse de l'exercice 3

Le test attend une durée fixe de 500 ms, puis lit le texte une seule fois. Sur le poste de Yanis, la réponse de l'API arrive en 100 ms ; sur le serveur de CI, plus chargé, elle arrive parfois après 500 ms : le message n'est pas encore affiché et le texte lu est vide. expect(await … .textContent()) est une assertion ordinaire, qui ne réessaye pas.

On supprime l'attente fixe et on utilise une assertion web-first, qui réessaye jusqu'à 5 secondes :

js
  await page.getByRole('button', { name: 'Réserver' }).click();
  await expect(page.getByRole('status')).toHaveText('Réservation enregistrée.');

Le test devient à la fois plus rapide quand tout va bien (il continue dès que le message apparaît) et plus robuste quand la machine est lente.

Tu avais trouvé ?

Exercice 4 sur 4 Quel niveau de test pour le front ? Réussi À revoir

Pour chaque vérification, choisis entre test unitaire, test de composant (Testing Library) et test de bout en bout (Playwright).

  1. La fonction formaterDate('2027-03-14') renvoie '14/03/2027'.
  2. Le bouton « Réserver » est désactivé tant que les deux dates ne sont pas remplies.
  3. Après une réservation, le matériel apparaît dans la page « Mes réservations », en relisant les données de l'API.
  4. Le formulaire affiche le message d'erreur renvoyé par l'API.
Voir la réponseMasquer la réponse de l'exercice 4
  1. Unitaire : une fonction pure.
  2. Composant : un comportement du composant seul, sans API.
  3. Bout en bout : un parcours qui traverse le front, l'API et les données, sur deux pages.
  4. Composant, avec une doublure de reserver qui renvoie une erreur : c'est le second test de FormulaireReservation.test.jsx. Un test de bout en bout sur un refus (PC-07) vérifie en plus que l'API et le front parlent le même format.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : TDD : le développement piloté par les tests, avec un kata corrigéRevoir le sommaire du cours