Des tests de bout en bout stables et rapides avec Playwright

Les règles appliquées sur un projet client pour garder une suite Playwright rapide et sans tests instables, du périmètre des tests aux fixtures et aux timeouts.

Cet article reprend le guide que j’ai écrit pour une équipe avec qui j’ai travaillé. Notre suite de tests de bout en bout avait deux objectifs : être stable 🧱 et rapide ⚡. Voici les règles qui nous y ont menés.

📖 Avant de commencer

Si vous n’avez jamais lu la documentation de Playwright, lisez au moins ces trois sections :

🚷 Règles générales

Des données aléatoires, contre l’instabilité

Les tests ne doivent jamais entrer en conflit.

Pour s’en assurer, on génère des données aléatoires avec Faker plutôt que des valeurs écrites en dur.

Un périmètre limité

On veut tester ce qui apporte de la valeur à l’utilisateur, donc on est tenté de tester tout un gros parcours.

Dans ce parcours, cherchez des morceaux indépendants qui apportent quand même de la valeur.

Parfois, ce n’est pas possible. L’onboarding se termine quand l’utilisateur accède à l’application, et il n’y a aucune valeur avant 😅. Ce test-là sera plus gros.

Pourquoi c’est important : tester un gros parcours donne soit un test énorme, soit plein de tests qui dépendent les uns des autres.

Prenons les factures. On pourrait écrire un test qui :

  • crée une facture,
  • puis la modifie,
  • puis la supprime.

C’est logique. Mais la deuxième étape dépend de la première, et la troisième de la deuxième.

Ce sont trois tests, un par action, idéalement un fichier chacun.

Mais il me faut une facture pour la modifier ou la supprimer !

Exact. C’est la règle suivante.

💽 Préparer les données de test

Un test a besoin de données pour l’une de ces deux choses : les lire (une requête) ou les modifier (une mutation).

Les données qu’on lit

Les données qu’on se contente de lire, pour vérifier un affichage, font partie du jeu de données de base du compte. On les fournit avec les fixtures Playwright.

Les fixtures étendent l’objet test de Playwright. Plus précisément, elles étendent l’environnement du test : Playwright s’assure que chaque test a ce dont il a besoin, ni plus, ni moins.

Nous avons créé des fixtures pour les données de base, comme un compte bancaire. Pour en utiliser une, il suffit de la récupérer dans les arguments du test :

test.describe('homepage', () => {
  //                                       on la récupère ici 👇
  test('affiche le nom du compte bancaire', async ({ page, bankAccount }) => {
    //                                   👇 l’objet complet est disponible 🎉
    await expect(page.getByText(bankAccount.alias)).toBeVisible();
  });
});

Pour changer le jeu de données, on modifie la fixture, ou le code qu’elle appelle.

Les données qu’on modifie

Imaginons qu’on veuille tester la modification d’une facture.

Il faut une facture. On pourrait être tenté de la créer via l’application, en cliquant partout, voire de réutiliser le test de création.

Ça ne ferait que ralentir le test, et on le veut rapide ⚡.

À la place, on crée la facture avant le test en appelant directement l’API. Utilisez l’API de l’application quand c’est possible. Sinon, écrivez une fonction utilitaire, ou demandez un endpoint à l’équipe backend.

import { test as baseTest } from './fixtures/test';

const test = baseTest.extend<object, { invoice: Invoice }>({
  invoice: [
    //        👇 une fixture peut utiliser d’autres fixtures
    async ({ organization }, use) => {
      // on crée l’objet via l’API
      const invoice = createInvoiceObject({
        number: faker.string.alphanumeric(8),
        totalAmount: 20_000,
        direction: 'INBOUND',
        status: 'IMPORTED',
      });
      await createInvoices(organization.id, [invoice]);
      // on le fournit au test
      await use(invoice);
    },
    { scope: 'worker' },
  ],
});

test('affiche la nouvelle facture', async ({ page, invoice }) => {
  //                                              👇 on l’utilise
  await expect(page.getByText(invoice.number)).toBeVisible();
});

Comme le test fait partie de la fonctionnalité, le backend est lui aussi concerné.

ℹ️ On pourrait simplifier tout ça. mergeTests combine des fixtures venant de plusieurs modules : on pourrait un jour écrire mergeTests(baseTest, invoiceFixture({ number: faker.string.alphanumeric(8) })).

On peut être tenté de réutiliser les données des fixtures de lecture. Ça peut marcher, mais si des tests concurrents partagent des données et que l’un d’eux les modifie, on introduit de l’instabilité. Le plus simple reste des données fraîches et dédiées pour chaque test de mutation.

Organisation des utilitaires

Les utilitaires sont répartis dans deux dossiers :

  • api : les fonctions qui appellent l’API,
  • objects : les fonctions qui construisent un objet métier, comme une facture.

Les utilitaires de objects infèrent le type de ce qu’on leur passe. Dans le type facture, supplierName est string | null. L’objet renvoyé par createInvoiceObject({ supplierName: 'Superman' }) a un supplierName de type string.

La plupart du temps, on construit un objet, puis on le passe à une fonction d’API :

import { createInvoice } from './utils/api/createInvoice';
import { createInvoiceObject } from './utils/objects/createInvoiceObject';

const invoice = createInvoiceObject({ number: faker.string.alphanumeric(8) });

test.beforeAll(async () => {
  await createInvoice(invoice);
});

test('...', async ({ page }) => {
  // ...
  await expect(page.getByText(invoice.number)).toBeVisible();
});

😄 Se concentrer sur le cas passant

Pour commencer, on se concentre sur le cas passant. Les cas limites relèvent des tests unitaires, au moins pour l’instant.

L’objectif reste une suite rapide ⚡.

📹 Enregistrer des vidéos pour la recette

Pour aider la recette à valider un nouveau test de bout en bout, activez l’enregistrement vidéo de Playwright dans playwright.config.ts :

export default defineConfig({
  use: {
    video: 'on',
    launchOptions: {
      slowMo: 1000, // ralentit chaque action pour que la vidéo soit lisible
    },
  },
});

Après l’exécution, les vidéos sont dans le dossier test-results.

🤖 Commandes

npx playwright test                    # tout lancer
npx playwright test invoices           # seulement les tests dont le chemin contient `invoices`
npx playwright test invoices --debug   # pas à pas avec l’inspecteur
npx playwright test --ui               # le mode UI complet

🦠 Traquer l’instabilité

Le rapport HTML est votre meilleur ami. Chaque exécution en CI devrait en publier un, avec les traces. (Sur ce projet, nous avions aussi écrit un petit reporter qui envoyait les tests instables vers une page partagée, pour suivre la tendance.)

Regardez pourquoi la première tentative a échoué. Un timeout ? Pourquoi ? L’API a mis du temps à répondre, et est-ce normal ? Une navigation trop longue ? Ou le test lui-même a dépassé son temps ?

Voilà les trois grandes familles d’échecs.

Timeout du test

Si c’est le test lui-même qui a dépassé son temps, demandez-vous d’abord : contient-il des parties testables séparément ? Si oui, découpez-le.

Ensuite : qu’est-ce qui compte vraiment dans ce test ? Puis-je sauter des étapes avec des appels d’API ? Si oui, faites-le. L’API sera toujours plus rapide que des clics dans l’interface.

Seulement si rien de tout ça n’aide, augmentez le timeout. Mieux vaut un test long qui passe qu’un test long exécuté deux fois parce qu’il est instable.

Timeout de navigation

On tombe sur un timeout de navigation de deux façons : le locator attendu n’apparaît jamais, ou l’API met trop de temps à répondre.

Si le locator n’apparaît jamais, l’application se comporte probablement de façon incohérente. La meilleure correction reste toujours de corriger l’application.

Si la page attend une API lente, augmentez le timeout de cet expect :

await expect(myLocator).toBeVisible({ timeout: 20_000 });

Et prévenez l’équipe backend : elle a un endpoint lent.

Pour une erreur plus claire, vérifiez l’URL avant de chercher des éléments dans la page :

await expect(page).toHaveURL(`/inbound-invoices/${invoice.id}`);
// avec un timeout
await expect(page).toHaveURL(`/inbound-invoices/${invoice.id}`, { timeout: 20_000 });