Tests en pratique - 03 - Flaky tests : le cancer du pipeline

Un flaky test passe et echoue aleatoirement. Comment les détecter, les diagnostiquer et les eliminer définitivement.

03 - Flaky tests : le cancer du pipeline

Ce que tu vas apprendre

  • Ce qu'est un flaky test et pourquoi c'est dangereux
  • Les causes les plus courantes
  • Comment détecter et isoler les tests instables
  • Comment corriger chaque type de flaky

Prerequisites

Avoir un pipeline CI en place (voir l'article 00 sur la CI/CD). Connaitre les bases des tests avec Bun.


Le test de Schrodinger

Mardi matin, le pipeline est rouge. Je regarde : un test d'envoi de mail a echoue. Je relance le pipeline sans rien changer. Il passe. Je relance encore. Il passe. Deux heures plus tard, il echoue a nouveau sur un autre push qui ne touche meme pas le code mail.

C'est un flaky test. Un test qui passe et echoue aleatoirement, sans modification du code. Le meme code, le meme test, des résultats différents.

Pourquoi c'est si dangereux

Le vrai danger d'un flaky test, c'est pas l'échec en soi. C'est la reaction de l'équipe. Apres trois faux positifs, les devs commencent a penser "c'est encore le test mail qui deconne". Ils relancent le pipeline mecaniquement. Et le jour ou un VRAI bug fait échouer le pipeline, ils relancent aussi sans regarder.

Sur paltemps.fr, on a eu une periode ou mes juniors relancaient chaque pipeline rouge sans meme lire le message d'erreur. Il a fallu corriger les flaky tests pour que le pipeline redevienne fiable, et que l'équipe recommence a prendre les échecs au serieux.

Un pipeline auquel personne ne fait confiance, autant ne pas en avoir.

Les causes les plus courantes

1. Timing et race conditions

Le classique. Ton test attend un résultat asynchrone mais n'attend pas assez longtemps. Ou il attend trop vite.

typescript// FLAKY : sleep arbitraire
it("sends notification", async () => {
  await sendNotification(userId);
  await Bun.sleep(100); // parfois 100ms suffisent, parfois non
  const notifs = await getNotifications(userId);
  expect(notifs).toHaveLength(1);
});

Le fix : utiliser du polling au lieu d'un sleep fixe.

typescript// STABLE : polling avec timeout
import { expect } from "bun:test";

it("sends notification", async () => {
  await sendNotification(userId);

  // Reessaie pendant 2 secondes max
  let notifs: Notification[] = [];
  const deadline = Date.now() + 2000;
  while (Date.now() < deadline) {
    notifs = await getNotifications(userId);
    if (notifs.length > 0) break;
    await Bun.sleep(50);
  }
  expect(notifs).toHaveLength(1);
});

2. État partage entre tests

Deux tests qui utilisent la meme table en base, le meme fichier temporaire, ou la meme variable globale. L'ordre d'exécution change le résultat.

typescript// FLAKY : les tests partagent la meme base
it("creates user", async () => {
  await db.insert(users).values({ email: "test@test.com" });
  const count = await db.select().from(users);
  expect(count).toHaveLength(1); // FAIL si l'autre test a deja insere
});

it("lists users", async () => {
  await db.insert(users).values({ email: "other@test.com" });
  const all = await db.select().from(users);
  expect(all).toHaveLength(1); // FAIL si l'autre test n'a pas nettoye
});

Le fix : nettoyer l'état avant chaque test.

typescript// STABLE : chaque test part d'une base propre
beforeEach(async () => {
  await db.delete(users); // vider la table
});

it("creates user", async () => {
  await db.insert(users).values({ email: "test@test.com" });
  const count = await db.select().from(users);
  expect(count).toHaveLength(1);
});

3. Dépendance a la date et l'heure

Un test qui passe a 14h mais echoue a 23h59 parce qu'il compare avec "aujourd'hui" et que le jour change entre le setup et l'assertion.

typescript// FLAKY : depend de l'heure reelle
it("formats today's date", () => {
  const result = formatDate(new Date());
  expect(result).toBe("2026-03-28"); // echoue demain
});

Le fix : mocker Date.now() ou passer une date fixe.

typescript// STABLE : date fixe
it("formats a specific date", () => {
  const result = formatDate(new Date("2026-03-28T12:00:00Z"));
  expect(result).toBe("2026-03-28");
});

4. Appels réseau a des APIs reelles

Un test qui appelle une vraie API externe. Le serveur est lent, indisponible, ou répond differemment.

typescript// FLAKY : depend d'un service externe
it("fetches weather", async () => {
  const weather = await fetch("https://api.weather.com/paris");
  const data = await weather.json();
  expect(data.temperature).toBeDefined();
});

Le fix : utiliser un fake ou un mock. Jamais d'appel réseau réel dans les tests (sauf les e2e explicitement prevus pour ca). Voir l'article sur les mocks pour les techniques.

typescript// STABLE : mock du fetch
import { mock } from "bun:test";

it("fetches weather", async () => {
  globalThis.fetch = mock(() =>
    Promise.resolve(new Response(JSON.stringify({ temperature: 22 })))
  );

  const weather = await getWeather("paris");
  expect(weather.temperature).toBe(22);
});

5. Dépendance a l'ordre d'exécution

Un test qui marche seulement si un autre test a tourne avant lui. Souvent parce que le premier test créé des donnees que le second utilise.

Le fix : chaque test doit etre complètement independant. Si un test a besoin de donnees, il les créé dans son beforeEach. Si tu peux lancer un test seul avec bun test --filter "mon test" et qu'il passe, c'est bon.

Comment détecter les flaky tests

La méthode brute mais efficace : lancer les tests en boucle.

bashfor i in $(seq 20); do bun test 2>&1 | tail -1; done

Si sur 20 runs tu as 18 passes et 2 échecs, tu tiens ton flaky.

Pour un suivi plus structure, GitLab CI a une fonctionnalité de détection de flaky tests dans les rapports JUnit. Les tests qui echouent puis passent sans changement de code sont automatiquement marques.

La quarantaine

Quand tu identifies un flaky test et que tu ne peux pas le corriger immédiatement, mets-le en quarantaine :

typescriptit.skip("flaky: sends notification (timing issue)", async () => {
  // TODO: fix polling logic, see issue #234
  // ...
});

C'est mieux que de le laisser pourrir le pipeline. Mais ne l'oublie pas. Un test en quarantaine qui reste 6 mois c'est un test mort. Mets une issue, assigne-la, fixe une deadline.

La regle d'or

Un test qui echoue doit signifier "il y a un bug dans le code". Pas "le serveur externe etait lent" ou "les tests ont tourne dans le mauvais ordre". Si ton équipe regle un pipeline rouge en cliquant "retry", ton pipeline a un problème de confiance. Et c'est un problème que seule la correction des flaky tests peut résoudre.


Article précédent : 02 - Coverage : le piège du 100%

Article suivant : 04 - Tests de regression

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.