Les tests instables échouent de façon intermittente sur un code inchangé, érodant la confiance de l'équipe dans la CI jusqu'à ce que les vrais échecs commencent à être ignorés.
Le vrai coût de l'instabilité
Le coût évident, c'est le temps : les développeurs qui relancent les pipelines, les testeurs qui investiguent des échecs qui ne mènent nulle part, les ingénieurs qui passent un vendredi après-midi à bissecter un test qui "a juste commencé à partir en vrille". Ça s'accumule vite. Une estimation conservative pour un test persistamment instable dans une équipe active : 30 à 60 minutes d'investigation par semaine.
Le coût caché est pire. Quand les échecs sont peu fiables, chaque échec devient suspect. Les vrais bugs sont écartés. L'instinct de réagir à un build rouge (précisément ce que la CI est censée construire) s'érode. Au bout du compte, votre suite est au vert au merge, rouge sur main, et personne ne sourcille.
Le coût psychologique est aussi réel. Les tests instables donnent l'impression que l'automatisation est peu fiable et fragile. Les ingénieurs juniors commencent à croire que l'automatisation est intrinsèquement capricieuse, ce qui influence la façon dont ils écriront leurs tests par la suite.
Le correctif commence par regarder les causes racines en face, pas par réflexe vers --retries.
Race conditions et timing asynchrone : la cause numéro un
La grande majorité des tests instables dans Playwright vient de problèmes de timing. Le test essaie de cliquer sur un bouton avant qu'il soit prêt, ou fait une assertion sur un texte avant que la requête réseau qui le peuple ne soit terminée. Sur une machine rapide, ça passe. Sur un runner CI lent, ça ne passe pas.
Le réflexe : ajouter un sleep.
// Le mauvais correctif — toujours instable, juste plus lent
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Save' }).click();Ça rend le test trois secondes plus lent et il continue d'échouer les mauvais jours de CI. On a échangé un problème contre deux.
Playwright attend automatiquement la plupart des choses. Quand vous appelez locator.click(), Playwright attend que l'élément soit visible, stable et non masqué avant d'agir. Le test devient instable seulement quand on court-circuite ce comportement, ou quand on attend quelque chose que Playwright ne connaît pas : une animation qui se termine, un spinner qui disparaît.
Le bon correctif : attendre la condition spécifique qui doit être vraie avant d'agir.
// Attendre qu'un spinner de chargement disparaisse avant d'interagir
await page.getByTestId('loading-spinner').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Save' }).click();
// Attendre une réponse réseau qui peuple la page avant l'assertion
await page.waitForResponse(
(resp) => resp.url().includes('/api/products') && resp.status() === 200
);
await expect(page.getByRole('table')).toBeVisible();
// Attendre qu'un bouton devienne actif après la validation du formulaire
const saveButton = page.getByRole('button', { name: 'Save' });
await expect(saveButton).toBeEnabled();
await saveButton.click();Chacun de ces exemples attend une vraie condition plutôt qu'une durée devinée. Playwright sonde la condition avec un timeout configurable (30 secondes par défaut), donc le test est à la fois fiable et aussi rapide que l'app le permet.
waitForTimeout plus d'une fois par semaine, traitez-le comme un code smell. Chaque instance est un test qui sera instable sous charge. Remplacez-les par des attentes basées sur des conditions.Ordre des tests et état partagé
Les tests qui passent seuls mais échouent dans la suite complète laissent presque toujours de l'état derrière eux. Un test crée un enregistrement, le suivant trébuche dessus. Un test définit un cookie, le suivant se comporte différemment à cause de lui. Un test modifie les paramètres d'un utilisateur, et tous les tests suivants pour cet utilisateur sont dans un état inattendu.
// Ce test laisse un "Test Item" dans la base de données à chaque exécution
test('add item to inventory', async ({ page }) => {
await page.goto('/inventory');
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Name').fill('Test Item');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Test Item')).toBeVisible();
// Rien n'est nettoyé
});
// Ce test échoue si le précédent a tourné d'abord — il trouve 2 articles en attendant 1
test('inventory shows one item', async ({ page }) => {
await page.goto('/inventory');
await expect(page.getByRole('row')).toHaveCount(2); // 1 ligne de données + 1 en-tête
});Le correctif : l'isolation. Chaque test doit configurer son propre état et le nettoyer après. Les fixtures Playwright sont le bon outil : elles lancent le setup avant chaque test et le teardown après, même si le test échoue.
import { test as base } from '@playwright/test';
type TestFixtures = {
testItem: { id: string; name: string };
};
const test = base.extend<TestFixtures>({
testItem: async ({ request }, use) => {
// Créer l'article avant le test
const response = await request.post('/api/inventory', {
data: { name: `Test Item ${Date.now()}` },
});
const item = await response.json();
await use(item); // exécuter le test
// Nettoyer après, même si le test a échoué
await request.delete(`/api/inventory/${item.id}`);
},
});
test('inventory item shows detail page', async ({ page, testItem }) => {
await page.goto(`/inventory/${testItem.id}`);
await expect(page.getByRole('heading', { name: testItem.name })).toBeVisible();
});Les fixtures garantissent le teardown. Les blocs afterEach ne s'exécutent pas si un test plante pendant le setup. Les fixtures, si. C'est la différence entre une isolation qui fonctionne la plupart du temps et une isolation qui fonctionne toujours.
Instabilité liée à l'environnement
Certains tests fonctionnent parfaitement sur votre MacBook et échouent un run sur deux dans GitHub Actions. La différence d'environnement est en cause. Coupables fréquents :
Fuseau horaire.new Date() renvoie des valeurs différentes selon où le test s'exécute. Un test qui fait une assertion sur une date formatée échouera en CI si le runner est en UTC et votre machine locale en UTC+3.
// Instable — dépend du fuseau horaire local de la machine
const today = new Date().toLocaleDateString('en-GB');
await expect(page.getByTestId('report-date')).toHaveText(today);
// Stable — fuseau horaire explicitement fixé
const today = new Date().toLocaleDateString('en-GB', { timeZone: 'UTC' });
await expect(page.getByTestId('report-date')).toHaveText(today);// Risqué en parallèle — deux workers peuvent générer le même ID dans la même milliseconde
const id = Date.now();
// Mieux — combiner l'horodatage avec l'index du worker
const id = `${Date.now()}-${workerInfo.workerIndex}`;playwright.config.ts et ne vous fiez pas aux breakpoints responsive sauf si vous les testez explicitement.
// playwright.config.ts
export default defineConfig({
use: {
viewport: { width: 1280, height: 720 },
},
});Instabilité des sélecteurs
Les noms de classes dynamiques et les IDs générés sont un piège. Des frameworks comme Tailwind et CSS Modules génèrent des noms de classes qui incluent des hashes de contenu. Les apps compilées génèrent parfois des IDs d'éléments basés sur l'ordre de build. Un sélecteur qui fonctionnait hier casse après une mise à jour de dépendance.
// Fragile — ce nom de classe est généré et changera
await page.locator('.tw-btn-primary-3af82').click();
// Fragile — ID généré, sans signification et instable
await page.locator('#ember-423').click();
// Aussi fragile — nth-child dépend de l'ordre
await page.locator('ul > li:nth-child(3)').click();Les locators sémantiques de Playwright lient le sélecteur à ce que fait l'élément plutôt qu'à son style ou sa structure. Ils restent stables au fil des refactoring parce qu'ils reflètent la sémantique visible par l'utilisateur, pas les détails d'implémentation.
// Stable — rôle + nom accessible
await page.getByRole('button', { name: 'Submit' }).click();
// Stable — texte du label
await page.getByLabel('Email address').fill('user@example.com');
// Stable — test ID (ajoutez data-testid à l'élément si nécessaire)
await page.getByTestId('submit-button').click();
// Stable — texte visible
await page.getByText('Order confirmed').waitFor();data-testid sont un contrat délibéré entre le test et l'application. Ils survivent aux refactoring CSS, aux changements de layout, et aux mises à jour de framework. Si votre app n'en a pas encore, commencez à en ajouter sur les éléments interactifs à forte valeur.Quand vous devez écrire un sélecteur CSS ou XPath, limitez sa portée et ancrez-le à un parent stable :
// Limité à une section nommée, pas à la page entière
const orderSummary = page.getByTestId('order-summary');
await expect(orderSummary.getByRole('cell', { name: 'Total' })).toBeVisible();Tests dépendants du réseau
Les tests qui appellent des services externes réels sont intrinsèquement instables. Une API tierce peut être lente, soumise à des limites de taux, ou temporairement indisponible. Un test qui appelle l'API Stripe en production en CI ne teste pas votre code. Il teste si Stripe est disponible.
Le pattern à reconnaître : tout test qui fait un vrai appel HTTP vers quelque chose hors de votre contrôle est un test instable en puissance.
Pour les APIs externes, moquez au niveau réseau :
test('checkout completes with payment confirmation', async ({ page }) => {
// Intercepter l'appel API Stripe et renvoyer une réponse contrôlée
await page.route('**/api/stripe/charge', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'ch_test_123',
status: 'succeeded',
amount: 4999,
}),
});
});
await page.goto('/checkout');
await page.getByLabel('Card number').fill('4242 4242 4242 4242');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByText('Payment confirmed')).toBeVisible();
});Pour les endpoints internes lents, utilisez page.waitForResponse avec un timeout généreux plutôt d'espérer que la réponse arrive dans le timeout d'action par défaut :
test('large report generates successfully', async ({ page }) => {
await page.goto('/reports');
await page.getByRole('button', { name: 'Generate Report' }).click();
// Attendre jusqu'à 60 secondes pour cet endpoint lent spécifique
await page.waitForResponse(
(resp) => resp.url().includes('/api/reports/generate') && resp.status() === 200,
{ timeout: 60_000 }
);
await expect(page.getByRole('link', { name: 'Download Report' })).toBeVisible();
});Si vous testez contre une vraie API que vous possédez, envisagez un environnement de test dédié réinitialisable entre les runs. Une base de données de test initialisée dans un état connu avant chaque run élimine toute une catégorie d'instabilité.
Le piège des retries
Playwright supporte les retries automatiques et ils sont vraiment utiles, mais c'est aussi l'outil le plus mal utilisé dans l'arsenal contre les tests instables.
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});Cette configuration est raisonnable comme dernier filet contre l'instabilité d'infrastructure : un problème réseau momentané en CI, un runner CI qui échoue occasionnellement à démarrer un navigateur. Ce n'est pas un correctif pour des tests qui ont de vrais problèmes.
Voici le problème avec les retries : un test qui passe au troisième essai consomme quand même le temps des deux premiers échecs. Avec retries: 2, une suite qui prend 10 minutes sur des runs propres peut en prendre 25 à 30 quand plusieurs tests sont instables. Les échecs sont cachés, le pipeline est dégradé.
Les retries sont acceptables dans ces conditions : l'instabilité vient manifestement de l'infrastructure. Pensez aux runners CI défaillants, aux échecs de démarrage de navigateur, ou aux timeouts réseau vers vos propres services. Vous avez aussi confirmé qu'il n'y a pas de problème dans le code de test, et vous traitez les retries comme temporaires pendant qu'un correctif plus profond est en cours.
Les retries sont néfastes quand ils constituent la première réponse à un nouveau test instable, ou quand ils masquent des problèmes de timing ou d'isolation dans le code de test. Ils le sont aussi quand le nombre de retries augmente au fil du temps, signe que la suite se dégrade.
// Mauvais : masquer un vrai problème de timing
export default defineConfig({
retries: 5, // Ce test continuait d'échouer, on a juste ajouté plus de retries
});
// Juste : retries comme filet de sécurité avec une limite basse et fixe
export default defineConfig({
retries: process.env.CI ? 1 : 0,
// Les vrais problèmes de timing et d'isolation sont corrigés dans le code
});retries au fil du temps plutôt que de le diminuer, votre problème de tests instables empire, il ne s'améliore pas. Le compteur de retries courant est une métrique de santé. Il doit tendre vers zéro.Investigation systématique : comment diagnostiquer un test instable
Quand un test commence à échouer de façon intermittente, suivez cette séquence au lieu de deviner et de bidouiller.
Étape 1 : Reproduisez-le de façon déterministe. Lancez le test 20 fois de suite :npx playwright test tests/checkout.spec.ts --repeat-each=20Comptez les échecs. Un test qui échoue 1 fois sur 20 est légèrement instable. Un test qui échoue 15 fois sur 20 a un vrai problème. Ça indique aussi combien l'effort de correction vaut. Un taux d'échec de 5% dans une suite qui tourne 50 fois par jour vous frappe quand même 2 à 3 fois par jour.
Étape 2 : Isolez-le. Lancez uniquement ce fichier de test. S'il passe de façon fiable en isolation mais échoue dans la suite complète, le problème est une pollution de test venant d'un autre test qui tourne avant lui.# Lancer en isolation
npx playwright test tests/checkout.spec.ts
# Lancer dans l'ordre de la suite pour reproduire la pollution
npx playwright test --workers=1// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry',
},
retries: 1, // réessayer une fois pour déclencher la capture de trace
});npx playwright test tests/checkout.spec.ts
npx playwright show-reportLe trace viewer montre une timeline avec des captures avant/après pour chaque action, toutes les requêtes réseau, et les logs console. Dans la majorité des cas, le point d'échec dans la trace révèle immédiatement si le problème est de timing, un élément manquant, ou une réponse réseau inattendue.
Étape 4 : Lancez en headed avec slow motion. Si la trace n'est pas concluante, regardez le test s'exécuter :npx playwright test tests/checkout.spec.ts --headed --slow-mo=500Le slow motion ajoute une pause de 500ms entre les actions. Ce qui semble instantané dans un run normal devient visible, et on voit souvent l'exact moment où l'interface n'est pas prête pour la prochaine interaction.
Étape 5 : Vérifiez ce qui tourne avant. Si les tests d'isolation ont révélé une pollution, trouvez le test précédent :# Lancer avec un seul worker pour un ordre déterministe, puis vérifier quel test a tourné avant celui qui échoue
npx playwright test --workers=1 --reporter=listCherchez des tests dans le fichier précédent qui créent des enregistrements, définissent des cookies, ou modifient l'état de l'application sans nettoyage.
Étape 6 : Appliquez le bon correctif. Voici ce qu'il faut faire selon la cause identifiée.- Problème de timing : remplacer
waitForTimeoutpar une attente basée sur une condition - Pollution de tests : ajouter un nettoyage dans
afterEachou convertir setup/teardown en fixtures - Instabilité de sélecteur : passer à
getByRole,getByLabel, ougetByTestId - Dépendance réseau : moquer l'appel externe avec
page.route - Différence d'environnement : fixer le fuseau horaire, le viewport, et toute valeur qui varie selon la machine
La plupart des tests instables se résolvent à l'étape 2 (isolation) ou à l'étape 3 (trace viewer). L'investigation a rarement besoin d'aller jusqu'à l'étape 6.
FAQ
Comment savoir si un test est instable ou s'il a vraiment détecté un bug ?Lancez-le 10 fois sur le même commit sans modifier le code. S'il échoue 1 à 3 fois sur 10, il est instable. S'il échoue de façon constante (7 fois ou plus sur 10), il a probablement détecté une vraie régression. La distinction compte : les tests instables nécessitent une investigation, les échecs constants nécessitent un correctif de bug.
Mon test échoue uniquement en CI, jamais en local. Pourquoi ?Les runners CI sont généralement plus lents, headless, et dans un fuseau horaire différent. Les causes les plus fréquentes spécifiques à CI sont les problèmes de timing. Le hardware local est souvent assez rapide pour masquer les race conditions. S'y ajoutent les différences de rendu headless pour les animations et les incompatibilités de fuseau horaire dans les assertions de dates. Lancez en local avec --slow-mo=500 pour simuler une machine plus lente, et vérifiez tout formatage de date pour des hypothèses de fuseau horaire.
test.skip ou test.fixme pour un test instable connu ?
test.skip l'exclut entièrement. test.fixme le marque comme censé échouer : le test s'exécute quand même, est prévu pour échouer, et devient une alerte visible s'il commence à passer (ce qui peut indiquer que le problème sous-jacent a changé). Pour un test genuinement instable sans correctif immédiat, test.skip avec un commentaire expliquant pourquoi et un lien vers le ticket de suivi est le meilleur choix. Un test.fixme inexpliqué n'est qu'une source de confusion.
J'ai ajouté un data-testid mais le test est toujours instable. Que vérifier d'autre ?
Un sélecteur stable ne garantit pas un test stable. Après avoir corrigé le sélecteur, vérifiez si l'élément est actionné avant d'être prêt (timing), si un état conflictuel vient d'un autre test (isolation), et si le test passe en isolation mais échoue en suite (pollution). La stabilité du sélecteur et l'isolation des tests sont des problèmes distincts.
On a 40 tests instables. Par où on commence ?Triez par taux d'échec, pas par irritation. Les tests qui échouent le plus souvent sont ceux qui dégradent le plus la fiabilité de la CI, corrigez-les en premier. En les corrigeant, des patterns émergeront. Si 15 d'entre eux partagent la même cause racine, un seul correctif s'applique à tous, comme ajouter une attente sur le spinner avant les interactions.
→ See also: Déboguer les Tests Instables: Un Guide Pratique | Stratégies d'Attente dans Playwright: Plus de sleep() | Isolation des Tests: Pourquoi Chaque Test Playwright Doit Être sans État