Playwright : obtenir des tests stables à 99%

Un test Playwright qui échoue une fois sur dix n’est pas un test à supprimer — c’est un signal à investiguer. Il peut révéler un test fragile, une hypothèse d’environnement implicite, ou un comportement de l’application sous certaines conditions que personne n’avait formalisé. Atteindre 99% de stabilité n’est pas une question de configuration : c’est le résultat de décisions d’architecture prises à chaque niveau de la suite. Locators, isolation, réseau, CI, fixtures — chaque couche contribue ou sabote la stabilité. Cet article documente les leviers qui font la différence, dans l’ordre d’impact réel.

Pourquoi les tests Playwright sont instables par défaut

Playwright est un outil déterministe. Le navigateur, lui, ne l’est pas. Un test instable est presque toujours le symptôme d’une hypothèse implicite sur l’état du système — une hypothèse qui tient en local mais qui est violée en CI, sous charge, ou dans un ordre d’exécution différent. Les causes racines se regroupent en quatre catégories. Chacune a ses solutions. Aucune ne se résout avec un waitForTimeout(2000).

Cause n°1 — critique

Timing — attentes arbitraires, animations, transitions CSS

Le waitForTimeout est la première cause de flakiness. Il encode une hypothèse sur la durée d’une opération — hypothèse qui tient sur la machine du développeur mais pas sur un runner CI avec 2 vCPU sous charge. Les animations CSS rajoutent une couche : un clic pendant une transition peut toucher un élément dans un état intermédiaire. Solution : attendre un état, jamais une durée.

Cause n°2 — critique

État partagé — base de données, localStorage, sessions entre tests

Un test qui dépend de l’état laissé par un test précédent n’est pas un test — c’est une transaction dans une séquence couplée. L’ordre d’exécution des tests en parallèle n’est pas garanti. Un test qui passe seul mais échoue en parallèle a presque toujours une dépendance d’état non isolée. Solution : isolation complète par test, reset explicite en beforeEach.

Cause n°3 — modérée

Dépendances réseau — APIs tierces, latence variable, réponses non mockées

Tester contre un service externe réel introduit une variabilité structurelle : le service peut être lent, indisponible, ou retourner des données différentes selon l’heure ou la région. Ce n’est pas une flakiness liée au test — c’est une flakiness liée à une dépendance hors de contrôle. Solution : intercepter et mocker les appels réseau tiers.

Cause n°4 — modérée

Locators fragiles — sélecteurs CSS, XPath, indices positionnels

Un locator couplé à la structure CSS ou DOM casse à chaque refactor visuel — même si le comportement testé n’a pas changé. C’est une source de faux négatifs qui érode la confiance dans la suite entière. Solution : hiérarchie de locators sémantiques — getByRole, getByLabel, getByTestId.

Règle diagnostique : si un test passe en local et échoue en CI, il y a une hypothèse d’environnement non déclarée. Mais la conclusion n’est pas automatiquement « le test est fragile ». Elle peut être « l’environnement PP reflète une vraie différence ». Corriger le test ou corriger l’environnement sont deux réponses également valides — après investigation, pas avant.

Levier n°1 — Les locators stables

Le choix du locator est la décision la plus impactante sur la stabilité à long terme. Un locator fragile transforme chaque refactor front en dette de tests. Playwright expose une hiérarchie de locators, du plus stable au plus fragile :

// ✗ Fragile — couplé à l'implémentation CSS
await page.locator('.btn-primary.checkout > span:first-child').click();

// ✗ Fragile — couplé à la structure DOM
await page.locator('xpath=//div[3]/button[1]').click();

// ✗ Fragile — dépend de l'ordre de rendu
await page.locator('button').nth(2).click();

// ✓ Stable — sémantique, accessible, résistant aux refactors CSS
await page.getByRole('button', { name: 'Valider la commande' }).click();

// ✓ Stable — contrat explicite avec le front-end
await page.getByTestId('checkout-submit').click();

// ✓ Stable — texte visible, couplé au comportement pas à l'implémentation
await page.getByText('Valider la commande').click();

Hiérarchie recommandée (cohérente avec les Testing Library guidelines) :

  • getByRole — premier choix. Teste ce que l’utilisateur perçoit et ce que les lecteurs d’écran annoncent. Un refactor CSS ne le casse pas.
  • getByLabel, getByPlaceholder — pour les champs de formulaire. Couplé à l’accessibilité, pas à la structure.
  • getByText — pour les contenus textuels stables qui ne changeront pas à chaque sprint.
  • getByTestId — contrat explicite via data-testid, quand les options sémantiques ne suffisent pas. Documente l’intention côté front.
  • CSS / XPath — dernier recours. À documenter avec un commentaire expliquant pourquoi les options stables ne convenaient pas.

Un bon locator décrit ce que l’utilisateur perçoit, pas comment le DOM est structuré. Si votre locator casse lors d’un refactor purement visuel, il est fragile par construction.

Levier n°2 — Les attentes explicites

Le waitForTimeout est un anti-pattern. Il encode une durée au lieu d’un état — durée qui tient sur la machine du développeur mais qui est systématiquement trop courte ou trop longue en CI selon la charge du runner. La règle absolue : attendre un état, jamais une durée.

// ✗ Anti-pattern — durée arbitraire
await page.waitForTimeout(2000);
await page.click('#submit');

// ✓ Attendre la visibilité de l'élément cible
await page.getByRole('button', { name: 'Valider' }).waitFor({ state: 'visible' });

// ✓ Attendre la réponse réseau
await page.waitForResponse(
  res => res.url().includes('/api/products') && res.status() === 200
);

// ✓ Attendre un état DOM — auto-retrying jusqu'au timeout
await expect(page.getByTestId('product-grid')).toBeVisible();
await expect(page.getByRole('progressbar')).toBeHidden();

// ✓ Attendre une navigation
await page.waitForURL('/confirmation**');

Les assertions Playwright (expect) sont auto-retrying par défaut. Elles attendent que la condition soit vraie plutôt que de la vérifier instantanément — c’est le comportement correct. Calibrer le timeout global en fonction du runner CI le plus lent, pas de la machine de développement.

// playwright.config.ts
export default defineConfig({
  expect: {
    timeout: 10_000,       // 10s par assertion (défaut : 5s)
  },
  use: {
    actionTimeout: 10_000,   // timeout par action (click, fill…)
    navigationTimeout: 30_000,
  },
});

Levier n°3 — L’isolation de l’état

Un test qui dépend de l’état laissé par un test précédent n’est pas un test — c’est une transaction dans une séquence couplée. L’isolation est la propriété la plus structurellement importante d’une suite stable. Elle se décline à trois niveaux.

Isolation du stockage navigateur

// Contexte navigateur vierge par test (comportement par défaut)
export default defineConfig({
  use: { storageState: undefined },
});

// Pour les tests nécessitant une authentification :
// générer le state une fois (project "setup"), le réutiliser en lecture seule
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: '**/auth.setup.ts' },
    {
      name: 'authenticated',
      use: { storageState: '.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});

Isolation de la base de données

// Reset via endpoint de test — fixture connue avant chaque test
test.beforeEach(async ({ request }) => {
  await request.post('/api/test/reset', {
    data: { fixture: 'checkout-flow' }
  });
});

// Alternative : cleanup après chaque test passé uniquement
test.afterEach(async ({ request }, testInfo) => {
  if (testInfo.status !== 'passed') return; // garder l'état sur échec pour debug
  await request.delete(`/api/test/orders/${orderId}`);
});

Levier n°4 — Le contrôle du réseau

Tester contre des services externes réels est une source de flakiness structurelle — le service peut être lent, indisponible, ou retourner des données variables. La solution n’est pas de retenter — c’est d’intercepter. page.route() est l’outil central.

// Mocker une réponse API avec fixture
await page.route('**/api/products**', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify(productFixture),
  });
});

// Simuler une erreur — impossible à reproduire en conditions réelles
await page.route('**/api/payment**', route =>
  route.fulfill({ status: 503 })
);

// Simuler une latence réseau dégradée
await page.route('**/api/search**', async route => {
  await new Promise(r => setTimeout(r, 3000));
  await route.continue();
});

// Enregistrer les requêtes pour assertions sur le comportement réseau
const calls: string[] = [];
await page.route('**/api/**', route => {
  calls.push(route.request().url());
  route.continue();
});

Le mocking réseau a une double valeur : il rend les tests déterministes et il permet de tester des scénarios d’erreur impossibles à reproduire en conditions réelles — timeout, 503, réponse malformée, latence extrême. Ces scénarios sont souvent les plus critiques à valider.

Levier n°5 — Les animations et le timing visuel

Les animations CSS sont une source de flakiness sous-estimée. Un clic pendant une transition d’entrée peut toucher un élément dans un état intermédiaire — techniquement visible, mais pas encore à la position attendue par Playwright. Deux approches complémentaires.

// Option 1 — Désactiver les animations globalement en test
export default defineConfig({
  use: {
    reducedMotion: 'reduce',  // réduit les animations à leur état final
  },
});

// Option 2 — Forcer via CSS injecté (plus radical)
await page.addStyleTag({
  content: `*, *::before, *::after {
    animation-duration: 0ms !important;
    transition-duration: 0ms !important;
  }`,
});

// Option 3 — Attendre l'état stable du composant animé
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(modal).not.toHaveClass(/animating/);
await modal.getByRole('button', { name: 'Confirmer' }).click();

Levier n°6 — La configuration CI

Un test parfaitement écrit en local peut être instable en CI pour des raisons d’environnement indépendantes du test lui-même. Les trois pièges les plus courants :

Piège CI n°1 — critique

Ressources système limitées — timeouts calibrés sur la mauvaise machine

Un runner CI avec 2 vCPU et 4 GB RAM exécute les tests 3 à 5 fois plus lentement qu’une machine de développement. Les timeouts calibrés en local sont systématiquement trop courts. Calibrer sur le runner le plus lent de la flotte — et préférer un timeout généreux plutôt qu’une flakiness chronique.

Piège CI n°2 — modéré

Parallélisme non contrôlé — partage implicite de ressources

Des tests parallèles qui partagent un port réseau, une base de données ou un volume de fichiers génèrent des conflits intermittents. Pattern correct à l’échelle : sharding Playwright + workers isolés + base de données ou schéma par worker. Coûteux à mettre en place, mais le seul moyen de paralléliser sans instabilité.

Piège CI n°3 — modéré

Snapshots visuels — référence générée en local vs rendu CI

Les snapshots toHaveScreenshot() générés en local ne correspondent pas aux snapshots CI si les fonts système ou le rendu headless diffèrent. Règle absolue : les snapshots de référence sont générés en CI, jamais en local. Versionner les snapshots en tant qu’artefacts CI.

// playwright.config.ts — configuration adaptée CI/local
export default defineConfig({
  workers: process.env.CI ? 2 : '50%',
  retries: process.env.CI ? 1 : 0,
  reporter: process.env.CI
    ? [['github'], ['html', { open: 'never' }]]
    : 'list',
  use: {
    trace: 'on-first-retry',
    video: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

Sur les retries : un retry sans investigation est une béquille qui masque un problème. Il est légitime temporairement si l’instabilité vient d’un environnement hors de contrôle — à condition d’être tracé et traité. Des retries qui compensent des tests structurellement fragiles rallongent le pipeline sans rien résoudre, et habituent l’équipe à ignorer les échecs.

Mesurer la stabilité — le taux de flakiness

On ne peut pas améliorer ce qu’on ne mesure pas. Le taux de flakiness se calcule sur un historique d’exécutions — pas sur le dernier run.

Taux de flakiness = (runs avec résultat incohérent) / (total des runs) × 100

Exemple — 100 exécutions du test "checkout flow" :
  ├── 92 passes au premier run
  ├──  6 fails sans retry
  └──  2 passes après retry  ← résultats incohérents

Flakiness = (6 + 2) / 100 × 100 = 8%   → objectif : ≤ 1%

Outils pour suivre cette métrique dans le temps :

  • Playwright HTML report — intègre les retries et les tests instables sur la dernière exécution. Point de départ.
  • GitHub Actions + JUnit XML — agréger les résultats sur 30 jours avec un script parsant les XML produits par Playwright. Pas d’outil externe requis.
  • Currents.dev / BuildPulse — services dédiés au tracking de flakiness sur l’historique CI, avec alertes et tendances par test. Utile dès que la suite dépasse 500 tests.

Règle de priorisation : traiter en premier les tests à forte flakiness sur les chemins critiques — checkout, authentification, onboarding. Un test instable sur un flow critique a un coût asymétrique : chaque faux négatif déclenche une investigation et érode la confiance dans toute la suite.

Architecture — les fixtures comme fondation

Les fixtures Playwright sont le mécanisme le plus sous-utilisé pour écrire des tests stables et maintenables. Elles encapsulent la mise en place d’état, le teardown, et les dépendances — de façon composable et sans duplication.

// fixtures/index.ts
import { test as base } from '@playwright/test';
import type { Page } from '@playwright/test';

type Fixtures = {
  authenticatedPage: Page;
  productInCart: { id: string; name: string };
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
    await page.getByLabel('Mot de passe').fill(process.env.TEST_USER_PASS!);
    await page.getByRole('button', { name: 'Se connecter' }).click();
    await page.waitForURL('/dashboard');
    await use(page);  // ← le test s'exécute ici, teardown après
  },
});

// tests/checkout.spec.ts — setup invisible, test lisible
import { test, expect } from '../fixtures';

test('valider une commande', async ({ authenticatedPage: page }) => {
  // page est déjà authentifiée — pas de beforeEach, pas de duplication
});

Les fixtures composent : une fixture peut dépendre d’une autre, Playwright gère l’ordre d’initialisation et le teardown. C’est le pattern qui permet d’écrire des tests lisibles sans duplication de setup — et de changer le comportement d’authentification en un seul endroit.

Nuances — ce que la stabilité à 99% ne garantit pas

Un objectif de stabilité à 99% est juste — mais il peut masquer des problèmes de conception si on n’est pas précis sur ce qu’on mesure.

Un test stable n’est pas nécessairement un test utile

Un test qui vérifie que la page se charge et qu’un bouton est visible est stable à 100% — et presque sans valeur. La stabilité est une propriété du test, pas une mesure de sa couverture ou de sa pertinence. Des tests stables mais superficiels laissent passer des régressions silencieuses sur les comportements qui comptent. La question à poser pour chaque test : « si ce comportement régresse, ce test va-t-il échouer ? » Si la réponse est non, la stabilité du test est un faux indicateur de santé.

La flakiness peut signaler un bug applicatif, pas un test fragile

Avant de corriger un test instable, il faut investiguer si l’instabilité ne révèle pas une race condition ou un comportement non déterministe dans l’application elle-même. Un test qui échoue une fois sur vingt sur une opération d’écriture concurrente peut être en train de détecter un bug réel que personne n’a encore observé en production — parce que la prod a moins de concurrence ou que les effets sont silencieux. Corriger le test sans investiguer l’application est une erreur de diagnostic.

Le mocking réseau crée une dérive par rapport à la réalité

Mocker toutes les APIs tierces produit des tests parfaitement déterministes — et parfaitement déconnectés du comportement réel des services. Si le contrat de l’API change (format de réponse, codes d’erreur, latence structurelle), les tests continuent de passer avec les fixtures figées. L’équilibre correct : mocker les APIs dans les tests d’intégration UI pour la stabilité, et maintenir un jeu de tests de contrat (contract testing) ou une suite de smoke tests en environnement réel pour détecter les dérives.

Matrice diagnostic — symptôme × cause × solution

Symptôme Cause probable Solution
Passe en local, échoue en CI Timeout trop court, ressources limitées, dépendance externe non mockée Calibrer timeouts sur CI, mocker les APIs tierces
Échoue aléatoirement partout État partagé, ordre d’exécution, race condition applicative Isolation par test, beforeEach reset, waitFor explicite — investiguer si bug applicatif
Échoue uniquement en parallèle Port réseau ou base de données partagés entre workers Isolation par worker, port dynamique, schéma DB par worker
Échoue après un refactor CSS Locator couplé à l’implémentation visuelle Migrer vers getByRole / getByTestId
Échoue sur les éléments animés Interaction pendant une transition CSS reducedMotion: 'reduce', attendre l’état stable du composant
Screenshots différents local / CI Fonts système, rendu headless, résolution d’écran Générer et versionner les snapshots de référence exclusivement en CI

Questions fréquentes — tests Playwright stables en CI

Pourquoi mes tests Playwright passent en local mais échouent en CI ?

C’est le symptôme le plus courant, et sa cause est presque toujours une hypothèse d’environnement implicite. Les trois causes principales : les timeouts calibrés sur la machine de développement (rapide) et trop courts pour le runner CI (lent avec 2 vCPU) ; des dépendances réseau réelles qui varient selon l’heure ou la région ; et des conditions de concurrence absentes en local (un seul worker) mais actives en CI (plusieurs workers en parallèle). Solution systématique : calibrer les timeouts sur le runner CI le plus lent, mocker les APIs tierces, et vérifier que les tests sont complètement isolés les uns des autres.

Qu’est-ce qu’un test flaky et comment le détecter dans Playwright ?

Un test flaky est un test dont le résultat — succès ou échec — n’est pas déterministe pour le même code. Il passe parfois, échoue parfois, sans que le code ait changé. Playwright détecte et signale les tests flaky dans son rapport HTML quand le mode retries est activé : un test qui échoue puis passe après retry est marqué comme flaky, pas comme passé. Pour mesurer le taux de flakiness sur la durée, agréger les résultats JUnit XML produits par Playwright sur plusieurs dizaines d’exécutions. Des outils comme Currents.dev ou BuildPulse automatisent ce tracking et alertent quand le taux d’un test dépasse un seuil défini.

Pourquoi utiliser getByRole plutôt que des sélecteurs CSS dans Playwright ?

getByRole est résistant aux refactors visuels — un changement de classe CSS, de structure DOM ou de framework de composants ne le casse pas, parce qu’il cible le rôle ARIA et le nom accessible de l’élément, pas son implémentation. Un sélecteur CSS comme .btn-primary.checkout > span casse à chaque refactor de styles, même si le comportement testé n’a pas changé — produisant des faux négatifs qui érodent la confiance dans la suite. En bonus, getByRole teste implicitement l’accessibilité du composant : si le rôle ARIA ou le nom accessible est mal défini, le locator ne trouve pas l’élément.

Faut-il utiliser page.waitForTimeout() dans les tests Playwright ?

Non — sauf cas exceptionnel documenté. waitForTimeout encode une durée arbitraire au lieu d’un état observable, ce qui produit deux problèmes : l’attente est trop longue sur des machines rapides (ralentit inutilement le pipeline) et trop courte sur des runners CI lents (génère de la flakiness). La règle est d’attendre un état : waitFor({ state: 'visible' }) pour la visibilité d’un élément, waitForResponse pour une réponse réseau, waitForURL pour une navigation, expect(...).toBeVisible() pour une assertion auto-retrying. Ces attentes sont déterministes — elles se résolvent dès que la condition est vraie, quelle que soit la vitesse de la machine.

Comment isoler l’état entre les tests Playwright pour éviter les dépendances ?

L’isolation opère à trois niveaux. Côté navigateur : Playwright crée un contexte navigateur vierge par test par défaut (cookies, localStorage, sessions réinitialisés). Pour l’authentification partagée, générer le storageState une fois dans un project « setup » et le réutiliser en lecture seule dans les tests — jamais le modifier dans les tests eux-mêmes. Côté données : appeler un endpoint de reset en beforeEach pour remettre la base de données dans un état connu. Côté réseau : mocker les APIs tierces pour éliminer la variabilité externe. Un test qui passe seul mais échoue en parallèle a presque toujours une dépendance d’état non isolée — investiguer avant d’augmenter le nombre de retries.

Quel est le bon nombre de retries à configurer dans Playwright CI ?

Un retry en CI (retries: 1) est un filet de sécurité raisonnable pour absorber des instabilités d’environnement transitoires — pic de charge du runner, latence réseau ponctuelle. Au-delà d’un retry, c’est un signal que les tests ont des problèmes structurels non résolus. Des retries élevés (2, 3) masquent la flakiness plutôt que de la résoudre : les tests passent finalement, le pipeline est vert, et personne n’investigate. La règle : configurer 1 retry en CI, tracer chaque activation dans le rapport, et traiter les tests flaky comme des bugs — avec un ticket, une investigation, et une correction — pas comme une normalité absorbée par la configuration.

Conclusion

La stabilité à 99% n’est pas un paramètre de configuration — c’est le résultat de décisions d’architecture prises à chaque niveau de la suite : choix de locators sémantiques, isolation complète par test, contrôle du réseau, calibrage CI sur le runner réel, fixtures composables. Chaque couche qui reste instable coûte du temps d’investigation, érode la confiance des développeurs, et transforme les tests en bruit plutôt qu’en signal.

Le signe d’une suite de tests mature n’est pas l’absence d’échecs — c’est la certitude que chaque échec signale un vrai problème dans le code. Quand les développeurs arrêtent de relancer les tests en espérant que ça passe, la suite fait son travail.