Logo ConstructiCat
CodeBust.
Parcourir la section ▾

Test erratique (instable).

Un test erratique (instable) passe et échoue par intermittence sur le même code parce que son résultat dépend du timing, de l'ordre, d'un état partagé ou d'autres facteurs non déterministes plutôt que du comportement testé.

##Signes et symptômes

Un test vert à une exécution et rouge à la suivante sans aucun changement de code est erratique. On le reconnaît au comportement humain qui l'entoure autant qu'au code : les développeurs relancent le CI « pour qu'il passe », ajoutent @Flaky/retry(3), ou mettent le test en quarantaine au lieu de le corriger.

Indices courants dans le code et le motif d'échec :

  • Timing/asynchrone : des « attentes » sleep/setTimeout, des promesses non attendues, ou des assertions qui font la course avec le système testé. Les échecs corrèlent avec la vitesse de la machine ou la charge du CI (voir Sleepy Test).
  • Dépendance à l'ordre : le test passe isolément mais échoue dans la suite complète, ou inversement. Mélanger l'ordre des tests change le résultat (voir Dépendance à l’ordre des tests).
  • État mutable partagé : collections statiques, ligne de base de données réutilisée, singleton, ou fixture mutée par un test antérieur.
  • Entrées non déterministes : Date.now()/new Date(), Math.random(), locale/fuseau horaire, ordre d'itération de hash-map, ou identifiants auto-générés.
  • Ressources externes : réseau, système de fichiers ou horloge réels. Les échecs ressemblent à des timeouts ou « connection refused », pas à des décalages d'assertion.
  • Assertions conditionnelles : expect caché dans un if/catch/callback, si bien que le test passe silencieusement quand la branche ne s'exécute jamais (voir Conditional Test Logic).
// Odeur : une « attente » codée en dur, un tableau partagé et une horloge réelle
const created = [];                       // partagé entre les tests → tests interagissants

test('shows a fresh receipt', async () => {
  render(<Checkout />);
  fireEvent.click(screen.getByText('Pay'));
  await new Promise(r => setTimeout(r, 300));   // on espère que la requête est terminée
  created.push('order-1');                       // fuit vers les tests suivants
  expect(screen.getByRole('status'))
    .toHaveTextContent(`Paid ${new Date().toISOString()}`); // change à chaque exécution
});

Cela correspond directement aux causes que Meszaros recense pour le Test erratique : Tests interagissants (les tests d'une même exécution partagent un état ; voir Dépendance à l’ordre des tests), Guerre des exécutions de tests (plusieurs exécutions simultanées se disputent une ressource partagée, comme une base de données ; voir Guerre des exécutions de tests), Test solitaire (ne réussit que si un autre test s'est exécuté avant lui, et échoue seul), Optimisme sur les ressources (suppose qu'une ressource externe est présente ; voir Optimisme sur les ressources), Fuite de ressources (ne nettoie pas), Test non reproductible (l'état laissé par une exécution modifie le résultat de la suivante, si bien que la première exécution se comporte autrement que les suivantes) et Test non déterministe (le temps, l'aléa et les courses asynchrones font réussir ou échouer la même exécution au hasard).

##Causes du problème

Pourquoi cela arrive

  • Timing implicite. Le code asynchrone est « synchronisé » par des sleeps fixes ou en n'attendant pas du tout. Le délai est une supposition : assez long aujourd'hui, trop court sous charge demain.
  • Couplage caché. Les tests partagent une base de données, un singleton, des variables de niveau module ou des fichiers sur disque. Les effets de bord d'un test deviennent les préconditions d'un autre (Tests interagissants / Guerre des exécutions de tests de Meszaros), si bien que les résultats dépendent de l'ordre et de ce qui s'est exécuté en parallèle.
  • Des entrées non déterministes s'infiltrent. L'heure réelle de l'horloge murale, Math.random(), la locale/le fuseau horaire et les collections non ordonnées varient d'une exécution et d'une machine à l'autre.
  • Dépendance optimiste à l'environnement. Les tests sollicitent un réseau/service en direct ou supposent qu'un fichier existe (Optimisme sur les ressources) et ne libèrent jamais ce qu'ils acquièrent (Fuite de ressources).
  • Assertions qui peuvent être sautées. Mettre expect dans une conditionnelle ou un .catch signifie que la vérification peut ne jamais s'exécuter, si bien que le test « passe » par accident.

Pourquoi c'est nuisible

  • Fausse confiance et défauts masqués. Un test instable peut échouer pour des raisons sans rapport avec la production, et une vraie régression peut se cacher derrière un échec que tout le monde suppose « juste de l'instabilité ». On ne distingue plus le signal du bruit.
  • Érosion de la confiance. Une fois qu'une suite est connue pour être instable, les développeurs ignorent les builds rouges et relancent par réflexe, ce qui entraîne l'équipe à ignorer entièrement la suite de tests.
  • Temps perdu et pipelines cassés. Les nouvelles tentatives, relances et enquêtes « est-ce moi ou le test ? » ralentissent tout le monde et bloquent le CI/CD sur des non-problèmes.
  • Mauvaise maintenabilité. Les tests erratiques sont difficiles à déboguer parce que l'échec n'est pas reproductible ; ils tendent à être désactivés plutôt que corrigés, réduisant discrètement la couverture réelle.

##Traitement

Traitez l'instabilité comme un défaut du test, pas comme une bizarrerie à contourner par des relances. Les relances servent à détecter l'instabilité, jamais à la masquer. Mettez le test en quarantaine s'il bloque le pipeline, puis remontez à sa cause racine.

1. Remplacez les sleeps par une attente basée sur une condition. Sondez l'état qui vous intéresse réellement au lieu de deviner une durée.

// avant — délai fixe et instable
fireEvent.click(screen.getByText('Pay'));
await new Promise(r => setTimeout(r, 300));
expect(screen.getByRole('status')).toBeInTheDocument();

// après — attendre la condition, puis affirmer
fireEvent.click(screen.getByText('Pay'));
expect(await screen.findByRole('status')).toBeInTheDocument();

2. Rendez les entrées déterministes. Injectez l'horloge ou utilisez de faux timers ; semez ou simulez l'aléa ; figez la locale/le fuseau horaire.

// avant : dépend de la date réelle
const label = receiptLabel();             // formate new Date() en interne
expect(label).toBe(`Paid ${new Date().toISOString()}`); // en concurrence avec l’horloge

// après (Vitest) : figer l’horloge, puis vérifier une valeur fixe
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
vi.useRealTimers();

// Jest : la même chose avec l’objet jest
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
jest.useRealTimers();

3. Isolez chaque test. Donnez à chaque test des fixtures fraîches, réinitialisez l'état partagé (BD, singletons, globales de module) dans beforeEach/afterEach, et évitez les variables mutables de niveau module. Exécutez la suite dans un ordre aléatoire (par ex. --seed/randomize de Jest, MethodOrderer.Random de JUnit) pour faire émerger tôt les dépendances à l'ordre.

4. Simulez les ressources externes. Mockez le réseau, le système de fichiers et les appels système pour que le test ne dépende jamais d'un service en ligne (soigne l'Optimisme sur les ressources) ; libérez/nettoyez toujours les ressources acquises (soigne la Fuite de ressources).

5. Attendez tout le travail asynchrone et affirmez sans condition. Renvoyez/attendez les promesses et les utilitaires de test asynchrones ; sortez les effets de bord des callbacks waitFor pour qu'ils s'exécutent une seule fois ; n'enterrez jamais expect dans un if/catch.

6. Vérifiez le correctif. Exécutez le test désormais déterministe de nombreuses fois (et sous charge / dans un ordre mélangé) pour confirmer sa stabilité avant de le sortir de quarantaine.

##Détecté par