Les tests instables échouent de façon intermittente sur un code inchangé. Ils érodent la confiance de l'équipe dans la CI jusqu'à ce que les vrais échecs commencent à être ignorés avec le bruit ambiant.

Pourquoi les tests instables sont pires que pas de tests

Quand un test échoue systématiquement, on le corrige. Quand un test échoue aléatoirement, l'équipe commence à ignorer les builds rouges. "C'est probablement juste le test de connexion instable" devient la réponse standard aux échecs CI. Finalement un vrai bug passe entre les mailles parce que personne n'a pris le build rouge au sérieux.

Les tests instables érodent la confiance dans toute votre suite de tests. C'est pourquoi les corriger vaut le temps investi, même quand le test lui-même n'est pas critique.

Les causes les plus courantes

Avant de déboguer, sachez ce que vous cherchez. Les tests instables viennent presque toujours de l'un de ces cinq endroits.

Problèmes de timing. La cause la plus courante de loin. Le test essaie d'interagir avec un élément avant qu'il soit prêt : avant qu'il apparaisse, avant qu'il soit activé, avant qu'une animation se termine. Le test passe quand la page charge vite et échoue quand elle charge lentement. Pollution de tests. Un test laisse derrière lui un état qui casse le test suivant. Un enregistrement créé, un cookie restant, une valeur localStorage modifiée. Les tests qui passent seuls mais échouent dans une suite, c'est presque toujours ça. Données de test partagées. Deux tests s'exécutent en parallèle et essaient tous les deux d'utiliser ou de modifier le même enregistrement. L'un gagne, l'autre échoue. Dépendances réseau. Un test fait un vrai appel API qui expire occasionnellement ou renvoie des données inattendues. Instabilité de l'ordre des éléments. Un test suppose que les éléments apparaissent dans un ordre spécifique (première ligne, deuxième bouton) mais l'ordre n'est pas garanti.

Commencez avec le trace viewer Playwright

Avant de modifier quoi que ce soit dans le code, reproduisez l'échec et capturez une trace. Le trace viewer est l'outil de débogage le plus puissant de Playwright : il enregistre chaque action, requête réseau, et snapshot DOM pendant l'exécution d'un test.

Activez le traçage dans playwright.config.ts :

export default defineConfig({
  use: {
    trace: 'on-first-retry',  // capturer la trace quand un test échoue et est retenté
  },
  retries: 1,  // réessayer une fois pour que la trace soit capturée
});

Lancez les tests, puis ouvrez le rapport :

npx playwright test
npx playwright show-report

Cliquez sur un test échoué. La vue trace affiche une timeline de chaque action avec des captures avant/après. Vous voyez exactement quelle étape a échoué, à quoi ressemblait la page à ce moment, et quelles requêtes réseau étaient en cours.

Ça seul résout environ la moitié des investigations de tests instables sans aucune supposition.

Corriger les problèmes de timing

Les problèmes de timing ressemblent à ça dans la sortie d'erreur :

Error: locator.click: Timeout 30000ms exceeded.
waiting for getByRole('button', { name: 'Submit' })

Ou :

Error: expect(locator).toBeVisible()
Received: hidden

Le réflexe est d'ajouter une attente. Le mauvais correctif :

// Mauvais — deviner combien de temps attendre
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();

Ça rend le test plus lent et toujours instable. Parfois 2 secondes ne suffisent pas.

Le bon correctif : attendre la condition spécifique qui doit être vraie avant l'action.

// Attendre qu'un indicateur de chargement disparaisse
await page.getByTestId('loading-spinner').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Submit' }).click();

// Attendre qu'un bouton devienne activé
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'visible' });
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();

// Attendre la fin d'une requête réseau
await page.waitForResponse(resp =>
  resp.url().includes('/api/items') && resp.status() === 200
);

L'auto-attente intégrée de Playwright gère la plupart des cas automatiquement. Quand l'auto-attente ne suffit pas, attendez la chose spécifique, pas une durée fixe.

Corriger la pollution de tests

Si les tests passent individuellement mais échouent ensemble, le problème est presque certainement un état qui fuit entre les tests.

Vérifiez ces sources de pollution :

Stockage navigateur. Si un test écrit dans localStorage ou sessionStorage et qu'un autre test en lit, vous avez de la pollution. Playwright crée un contexte de navigateur frais pour chaque fichier de test par défaut, mais les tests dans le même fichier partagent le contexte par défaut dans certaines configurations.

// Vider le stockage avant chaque test du fichier
test.beforeEach(async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.evaluate(() => {
    localStorage.clear();
    sessionStorage.clear();
  });
});

État de base de données. Si vos tests créent des enregistrements et ne les nettoient pas, les tests qui s'exécutent après voient des données inattendues.

test.afterEach(async ({ request }) => {
  // Supprimer l'enregistrement de test créé pendant le test
  await request.delete('https://lab.becomeqa.com/api/items/test-item-id');
});

État global des tests. Si vous utilisez des variables globales dans vos fichiers de test pour partager des données entre tests, ne le faites pas. Chaque test doit être autonome.
Lancez vos tests avec --repeat-each=3 pour voir s'ils sont stables quand répétés. Un test qui échoue au deuxième run laisse fuiter de l'état. npx playwright test --repeat-each=3 tests/login.spec.ts

Corriger les conflits d'exécution parallèle

Playwright exécute les tests en parallèle par défaut sur plusieurs workers. Si deux tests essaient de modifier le même enregistrement ou d'utiliser le même compte utilisateur simultanément, ils entrent en conflit.

Le correctif dépend de la situation.

Utilisez des données de test uniques par test. Au lieu de toujours utiliser admin@becomeqa.com, générez un identifiant unique pour chaque run de test :

const uniqueId = Date.now();
const testEmail = `test-${uniqueId}@example.com`;

Isolez les tests parallèles. Regroupez les tests qui entrent en conflit dans le même fichier et configurez ce fichier pour s'exécuter avec un seul worker :

// En haut du fichier
test.describe.configure({ mode: 'serial' });

Cela exécute tous les tests du fichier de façon séquentielle, évitant les conflits.

Utilisez des données de test séparées par worker. Playwright passe un workerIndex aux fixtures :

const workerEmail = `test-worker-${workerInfo.workerIndex}@example.com`;

Utiliser les retries avec discernement

Playwright supporte les retries automatiques pour les tests instables :

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
});

Les retries en CI masquent les problèmes plutôt que de les corriger. C'est toutefois un outil pratique quand l'instabilité vient de l'infrastructure (timeouts réseau, variance des machines CI) et non de bugs dans le code de test.

La règle : les retries sont acceptables pour l'instabilité d'infrastructure. Ils ne sont pas acceptables comme substitut à la correction de vrais problèmes de timing ou d'isolation.

Définir retries: 3 sans investiguer pourquoi les tests échouent, c'est comment vous vous retrouvez avec une suite qui prend 3x plus longtemps à s'exécuter et n'a toujours pas de test en lequel vous faites vraiment confiance.

Mettre en quarantaine les tests persistamment instables

Si un test est instable et que vous ne pouvez pas le corriger immédiatement, mettez-le en quarantaine. Ne le laissez pas dans la suite principale en train d'échouer aléatoirement.

test.skip('le flux de paiement se complète avec succès', async ({ page }) => {
  // Instable à cause des timeouts de l'API de paiement — suivi dans JIRA-1234
  // TODO: mocker la réponse de l'API de paiement au lieu d'appeler la vraie
});

Un test ignoré avec un commentaire vaut infiniment mieux qu'un test instable qui conditionne l'équipe à ignorer les builds rouges.

Un workflow de débogage systématique

Quand vous rencontrez un test instable, travaillez dans cet ordre :

1. Capturez la trace : lancez avec retries: 1 et trace: 'on-first-retry', regardez le point d'échec exact

2. Lancez-le 10 fois : npx playwright test --repeat-each=10 tests/your.spec.ts, voyez à quelle fréquence il échoue

3. Lancez-le en isolation : npx playwright test tests/your.spec.ts, s'il passe seul, c'est de la pollution de tests

4. Lancez-le en mode headed : npx playwright test --headed --slow-mo=500, regardez-le échouer au ralenti

5. Vérifiez l'onglet réseau dans la trace : les requêtes échouent-elles ou expirent-elles ?

6. Ajoutez des attentes explicites pour la condition spécifique qui doit être vraie avant l'action qui échoue

7. Vérifiez l'état partagé : que fait le test avant celui-ci ?

La plupart des tests instables se résolvent à l'étape 3 ou à l'étape 6.

FAQ

Comment savoir si un test est vraiment instable vs. s'il a détecté un vrai bug ?

Lancez-le 10 fois sur le même commit. S'il échoue 2 fois sur 10, il est instable. S'il échoue 10 fois sur 10, il a trouvé un bug.

Mon test n'échoue qu'en CI, jamais en local. Pourquoi ?

Les machines CI sont plus lentes et ont moins de mémoire. Les problèmes de timing invisibles en local apparaissent sous charge. Lancez en local avec --slow-mo=500 pour simuler une machine plus lente. Vérifiez aussi si CI utilise une URL de base ou des variables d'environnement différentes.

Dois-je utiliser test.fixme ou test.skip pour les tests connus comme instables ? test.skip exclut le test entièrement. test.fixme le marque comme cassé mais l'exécute quand même. Le test est censé échouer, et il devient un échec s'il commence à passer (ce qui vous alerte de le vérifier). Pour les tests instables connus qui ont besoin d'être corrigés, test.fixme est le choix le plus honnête. La trace montre que l'élément était visible mais le clic a quand même échoué. Que s'est-il passé ?

L'élément était visible mais probablement recouvert par un autre élément (une modale, une infobulle, un en-tête fixe). Vérifiez isVisible() vs isInViewport(). Vous devrez peut-être faire défiler jusqu'à l'élément d'abord : await locator.scrollIntoViewIfNeeded().

→ See also: Stratégies d'Attente dans Playwright: Plus de sleep() | Playwright Trace Viewer: Déboguez les Tests Échoués Comme un Pro | Isolation des Tests: Pourquoi Chaque Test Playwright Doit Être sans État | Tests Instables: Pourquoi ils Arrivent et Comment les Éliminer