680 exécutions, zéro retry : comment prouver réellement qu'un test instable est corrigé


« Le test instable est corrigé — il est passé. »
Non. Il est passé une fois. Ce n’est pas corrigé.
Si le taux d’échec initial était de 0,5 %, une seule exécution réussie vous donne 99,5 % de confiance. Ça semble élevé jusqu’à ce que votre CI tourne 200 fois par semaine et que vous constatiez des échecs « aléatoires » chaque lundi matin. Une seule exécution réussie n’est pas une preuve. C’est un pile ou face que vous avez eu la chance de gagner.
J’exécute chaque correctif de test instable 680 fois consécutives avant de le considérer comme terminé. Zéro retry. Zéro allowedFlakes. S’il échoue une seule fois sur 680 exécutions, le correctif n’est pas réel.
Les mathématiques qui changent votre point de vue
C’est un problème de probabilité binomiale. Si un test a un vrai taux d’échec de p, la probabilité qu’il réussisse n exécutions consécutives est (1 − p)^n. La probabilité d’observer au moins un échec est 1 − (1 − p)^n.
Pour un test avec un taux d’échec de 0,5 % :
| Réussites consécutives | P(tout passe) | P(au moins 1 échec) | Confiance que le bug existe encore |
|---|---|---|---|
| 1 | 99,50 % | 0,50 % | Quasi nulle — vous n’avez rien appris |
| 10 | 95,11 % | 4,89 % | Faible |
| 50 | 77,83 % | 22,17 % | Modérée |
| 100 | 60,65 % | 39,35 % | On progresse |
| 200 | 36,77 % | 63,23 % | Pile ou face |
| 460 | 10,00 % | 90,00 % | 90 % de confiance que le correctif est réel |
| 680 | 3,31 % | 96,69 % | ~97 % de confiance à un taux d’échec de 0,5 % |
| 1000 | 0,67 % | 99,33 % | 99 %+ de confiance |
Il existe aussi un raccourci utile appelé la règle de trois : si vous observez zéro échec en n essais, la borne supérieure du taux d’échec à 95 % de confiance est approximativement 3/n. Pour n = 680, cela donne 3/680 = 0,44 %. Ainsi, 680 réussites sans aucun échec signifie que vous pouvez être sûr à 95 % que le vrai taux d’échec est inférieur à 0,44 %.
Pourquoi 680 précisément ? C’est un nombre pratique : 8 workers parallèles × 85 itérations chacun. Cela tient dans une seule exécution de stress test, se termine en un temps raisonnable, et pousse la borne supérieure des taux d’échec probables en dessous du seuil auquel un pipeline CI typique verrait des échecs hebdomadaires.
L’essentiel n’est pas que 680 soit un nombre magique. L’essentiel, c’est qu’un ne suffit pas, dix ne suffisent pas, et qu’il faut faire le calcul pour votre situation spécifique.
Pourquoi la plupart des « correctifs » échouent
Après des mois d’application de ce protocole, trois schémas ont émergé parmi les correctifs échoués — des tests qui passaient une fois, voire cinquante fois, puis échouaient à nouveau.
Schéma 1 : le timeout augmenté
Le non-correctif le plus courant. Un test échoue parce qu’un élément met 6 secondes à apparaître. Le « correctif » change le timeout de 5 secondes à 15 secondes. Il passe aujourd’hui. Demain, sous la charge du CI avec des workers de test parallèles en compétition pour les ressources, cet élément met 18 secondes.
// ❌ Le non-correctif : augmenter le timeout
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible({ timeout: 15000 });
// ✅ Le vrai correctif : attendre la condition réelle, pas un timer arbitraire
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForResponse(resp =>
resp.url().includes('/api/submit') && resp.status() === 200
);
await expect(page.getByText('Success')).toBeVisible();Un timeout n’est pas un correctif — c’est un pari que le système sera toujours au moins aussi rapide. Ce pari finit toujours par être perdu.
Schéma 2 : la fuite d’état
Le test B passe quand il est exécuté seul, échoue quand il est exécuté après le test A. Le test A laisse derrière lui un cookie, une entrée dans le local storage ou une ligne en base de données qui modifie les conditions initiales du test B. Le « correctif » ajoute un nettoyage dans beforeEach. Mais le nettoyage lui-même est fragile — il efface l’état connu mais oublie l’artefact qui n’est créé que dans certaines conditions spécifiques du test A.
// ❌ Nettoyage fragile : effacer l'état connu
test.beforeEach(async ({ page }) => {
await page.evaluate(() => localStorage.clear());
});
// ✅ Isolation robuste : contexte neuf pour chaque test
// Dans playwright.config.ts — chaque test obtient un contexte navigateur vierge
const config: PlaywrightTestConfig = {
use: {
// Chaque test démarre avec zéro cookie, zéro storage, zéro historique
storageState: undefined,
contextOptions: {
ignoreHTTPSErrors: true,
},
},
// Entièrement parallèle — aucun état partagé entre les workers
fullyParallel: true,
};Schéma 3 : la condition de concurrence
Un clic se déclenche. Une requête réseau démarre. Le test vérifie le résultat. Mais l’assertion s’exécute avant l’arrivée de la réponse. Ça fonctionne sur votre machine locale rapide. Ça échoue 0,5 % du temps en CI où la contention des ressources ajoute 200 ms de latence à chaque appel réseau.
// ❌ Condition de concurrence : assertion avant l'arrivée des données
await page.getByRole('button', { name: 'Load Data' }).click();
await expect(page.getByTestId('results-count')).toHaveText('42');
// ✅ Attendre la réponse réseau, puis vérifier le résultat affiché
await page.getByRole('button', { name: 'Load Data' }).click();
await page.waitForResponse(resp =>
resp.url().includes('/api/data') && resp.status() === 200
);
await expect(page.getByTestId('results-count')).toHaveText('42');Arrêtez de dire « instable » — commencez à classifier
Le mot « instable » est une impasse diagnostique. Il met fin à l’investigation. C’est l’équivalent en test d’un médecin qui dit « vous êtes malade ». Techniquement vrai. Totalement inutile.
Chaque échec de test a une cause racine. Classifiez-la :
| Classe de cause racine | Symptôme | Catégorie de correctif |
|---|---|---|
| Défaut de timing | Passe en local, échoue en CI sous charge | Attendre des conditions, pas des timers |
| Fuite d’état | Échoue uniquement après certains tests spécifiques | Isolation — contexte neuf par test |
| Condition de concurrence | Échoue de manière intermittente sur des assertions rapides | Attendre le réseau/l’état, puis vérifier |
| Contention de ressources | Échoue en parallèle, passe en série | Isolation des workers ou verrous de ressources |
| Dérive d’environnement | Échoue en staging mais pas en dev | Fixtures adaptées à l’environnement |
Une fois la classe identifiée, vous pouvez la rechercher. C’est du renforcement prédictif.
Renforcement prédictif : corriger les tests avant qu’ils n’échouent
C’est le changement méthodologique qui compte plus que le protocole des 680 exécutions lui-même.
Après avoir corrigé 3 tests instables connus qui partageaient tous la même cause racine — des conditions de concurrence entre les gestionnaires de clic et les réponses réseau — j’ai recherché le même schéma dans l’ensemble de la suite. Un grep sur les instructions d’assertion qui suivent immédiatement des actions de clic sans waitForResponse ou waitForLoadState intermédiaire.
# Find potential race conditions: assertions immediately after clicks
# with no waitForResponse in between
grep -n "\.click()" tests/**/*.spec.ts | while read line; do
file=$(echo "$line" | cut -d: -f1)
linenum=$(echo "$line" | cut -d: -f2)
# Check if the next 3 lines contain a waitForResponse
nextlines=$(sed -n "$((linenum+1)),$((linenum+3))p" "$file")
if echo "$nextlines" | grep -q "expect\|toHave\|toBe" && \
! echo "$nextlines" | grep -q "waitFor"; then
echo "POTENTIAL RACE: $file:$linenum"
fi
doneJ’ai trouvé 3 autres tests présentant le même schéma de vulnérabilité — et je les ai renforcés avant qu’ils n’échouent jamais en CI.
C’est la différence entre le test réactif (attendre que ça casse, puis corriger) et le renforcement prédictif (classifier le schéma d’échec, puis balayer la suite entière).
Le pattern FAST / STANDARD / EXTENDED
Remplacez les nombres magiques ad hoc par des constantes de polling standardisées. Chaque appel waitFor dans la suite référence une constante nommée, pas une estimation :
// test-constants.ts
export const TIMEOUTS = {
FAST: 2_000, // Elements that should appear immediately after navigation
STANDARD: 10_000, // API responses and re-renders
EXTENDED: 30_000, // Complex operations, streaming, file uploads
STRESS: 60_000, // Only used in stress test configurations
} as const;
// In tests:
await expect(page.getByRole('heading')).toBeVisible({
timeout: TIMEOUTS.FAST
});
await expect(page.getByTestId('search-results')).toHaveCount(10, {
timeout: TIMEOUTS.STANDARD
});Quand un test a besoin de EXTENDED, c’est un signal. Cela signifie soit que l’opération est véritablement lente (acceptable), soit que quelque chose dans l’architecture bloque (à investiguer). Les constantes nommées rendent ces signaux visibles lors de la revue de code.
Le protocole de stress test
Voici la configuration exacte que j’utilise pour la validation par stress test :
// playwright.stress.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 8,
repeatEach: 85, // 8 × 85 = 680 total runs
retries: 0, // Zero tolerance — a single failure invalidates the fix
timeout: 30_000,
use: {
trace: 'on-first-retry', // Won't fire with 0 retries — that's the point
},
reporter: [
['list'],
['json', { outputFile: 'stress-results.json' }],
],
});# Run stress validation against a specific test file
npx playwright test tests/checkout-flow.spec.ts --config=playwright.stress.config.ts
# Verify: all 680 must pass
cat stress-results.json | jq '.stats | {total: .expected, passed: .expected, failed: .unexpected}'Si les 680 passent : le correctif est réel. Livrez-le.
Si certains échouent : ne regardez pas seulement le nombre d’échecs. Regardez quels workers ont échoué et quelles itérations au sein de ces workers. Des échecs concentrés dans les itérations tardives suggèrent un épuisement des ressources (fuite mémoire, épuisement du pool de connexions). Des échecs répartis aléatoirement suggèrent que le correctif est incomplet. Des échecs uniquement sur certains workers spécifiques suggèrent un problème d’isolation.
La leçon fondamentale
Les tests instables ne sont pas une catégorie de nuisance. Ce sont une opportunité de diagnostic. Chaque test « instable » vous communique quelque chose de précis sur le comportement de votre système dans des conditions que vous n’aviez pas anticipées. Le test qui échoue de manière intermittente en CI mais passe en local rapporte une information réelle : votre application se comporte différemment sous contention de ressources. Ce n’est pas le test qui est peu fiable. C’est le test qui est plus honnête que votre environnement local.
Le passage de « corriger ce test instable » à « classifier et éliminer ce schéma d’échec dans toute la suite » est le changement le plus impactant que j’ai apporté à ma méthodologie de test. Cela transforme un jeu réactif de tape-taupe en une réduction systématique de la surface d’échec.
Arrêtez de qualifier vos tests d’instables. Commencez à classifier vos échecs. Et arrêtez de considérer un correctif comme terminé parce qu’il est passé une fois.
680 exécutions. Zéro retry. De la confiance statistique, pas de l’espoir.
