⚡ Tests intermittents : budget relance, échec non masqué
Découvrez comment gérer les tests intermittents : budget de relance, état isolé, graine stable et traçabilité. Boostez vos tests automatisés en CI.

Test intermittent : pourquoi une relance verte ne prouve rien
Un contrôle de contrat échoue une première fois, puis réussit au second lancement. Dans une session de programmation en binôme, un assistant propose alors une solution qui a l'air logique : réessayer l'assertion trois fois et garder le premier résultat positif. Le correctif tient en quelques lignes. C'est justement là que ça coince.
Une relance réussie ne dit pas, à elle seule, si le service fonctionne, si un traitement asynchrone vient de se terminer, ou si le deuxième essai a juste profité de données laissées par le premier. En pratique, elle peut transformer un défaut reproductible en succès difficile à expliquer, et installer une règle de délai au mauvais endroit.
La bonne question n'est donc pas « combien de fois faut-il réessayer ? », mais « qui décide de la relance, avec quel état et quelle preuve ? ». Voici une méthode concrète pour examiner un test intermittent, consigner les essais et empêcher qu'un résultat vert pris isolément devienne une validation implicite. Le script présenté est un exemple pour illustrer le raisonnement, pas un outil destiné à tourner en production ni une étude de taux de flakiness.
Pourquoi un second essai peut induire en erreur
Prenons un service qui expose GET /orders/:id. Le test crée une commande, attend 200 millisecondes, puis vérifie que son statut est packed. Au premier lancement, la réponse indique picking. La commande est relancée dans le même terminal, sans nettoyage de la base. Cette fois, le statut attendu apparaît et le processus se termine avec le code 0.
À première vue, ça raconte une histoire simple : « il fallait juste un peu plus de temps ». Sauf que les éléments observés ne suffisent pas à conclure. Le deuxième résultat peut aussi venir d'un worker qui a fini entre-temps, d'une ligne conservée en base, d'un cache déjà chaud, ou d'une différence entre les deux exécutions.
La distinction compte. Un test qui vérifie un contrat ne doit pas devenir une mesure approximative de la patience du développeur. Si le statut doit évoluer de façon asynchrone, cette attente fait partie du contrat : événement, état intermédiaire, délai maximal, et comportement en cas d'échec. Sans ça, « réessayer jusqu'à ce que ça passe » remplace une observation par une supposition.
Trois questions avant de modifier le code
Avant de toucher à l'assertion ou au service, clarifiez trois points :
- Quel processus contrôle le temps ? Le test, l'application, la base de données ou un worker ?
- Quelle graine et quel état ont été utilisés ? La graine doit être notée avant la relance, pas reconstruite après.
- Une autre exécution, dans un environnement distinct, reproduit-elle l'échec ? Un second lancement sur le même volume de données n'est pas une expérience indépendante.
Dans l'exemple, l'assistant avance une explication possible. Rien dans les journaux ne la vérifie. La formule peut être vraie dans certains systèmes distribués, mais elle ne dit rien de solide sur un champ status. Une hypothèse plausible n'est pas une preuve, et elle ne devrait pas se glisser dans le correctif comme si elle était établie.
La règle de base est de séparer ce qu'on observe de ce qu'on suppose. « La réponse contenait picking au premier essai et packed au second » est une observation. « Le service est finalement cohérent » est une hypothèse à tester. Cette séparation évite de modifier l'application pour corriger un problème qui vient peut-être d'ailleurs : données, timing, ou dispositif de test.
Pour aller plus loin, vous pouvez aussi documenter la stratégie de tests automatisés et la gestion des données de test. Ces sujets se recoupent souvent quand un test ne passe qu'après la première exécution.
Pourquoi ne pas relancer dans l'assertion
La proposition qui revient souvent consiste à encapsuler le contrôle dans une boucle : trois requêtes, un délai croissant, puis retour dès qu'une réponse correspond. Sur le moment, c'est un patch local. Mais au fond, ça déplace une politique de temps et de répétition dans un helper de test.
async function expectPacked(client, id) {
let last = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
last = await client.get(`/orders/${id}`);
if (last.body && last.body.status === "packed") return last;
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
}
throw new Error("status never reached packed");
}
Le problème n'est pas de répéter. Un système asynchrone peut demander une attente bornée. Le vrai souci, c'est de cacher cette politique dans une assertion qui donne ensuite l'impression d'avoir juste vérifié le contrat. Le prochain éditeur peut augmenter le nombre d'essais ou allonger les délais pour faire disparaître un échec gênant. Le test reste vert, mais sa signification a changé.
Ce que la boucle change
Une relance silencieuse rend la lecture du résultat plus difficile : pour le développeur, pour le CI, et pour la personne qui relit le diff. Si seul le succès final est visible, on ne sait plus combien de tentatives ont été nécessaires, ni si les réponses précédentes étaient identiques. Et il y a un autre piège : la répétition peut réutiliser une ligne créée lors du premier essai. Dans ce cas, le test ne confirme pas le comportement attendu sur un état propre.
Il faut distinguer deux cas :
- Attendre un comportement asynchrone prévu : le contrat définit l'état attendu, la durée maximale et la manière d'observer. La répétition fait alors partie du dispositif explicite.
- Relancer juste pour obtenir un résultat vert : la relance sert à contourner un échec sans établir la cause. Là, elle ne transforme pas l'échec en preuve de bon fonctionnement.
Dans 80 % des cas où un test isolé passe au second lancement, je commencerais par vérifier l'état partagé et la reproductibilité avant d'ajouter des tentatives à l'assertion. Ce n'est pas une règle absolue : si le comportement attendu est asynchrone, il faut le tester comme tel. Mais un délai arbitraire ajouté au helper ne remplace pas cette modélisation.
Le modèle utilisé n'est pas le point de décision
Une autre réaction consiste à s'arrêter au motif que l'assistant utilisé est gratuit ou non déterministe. Ce raisonnement ne traite pas le défaut observé. Un modèle payant peut aussi proposer la même boucle, et un modèle gratuit peut aider à construire un journal d'essais. La qualité de la décision dépend du harnais, des preuves et de la revue humaine, pas du prix de l'outil.
L'assistant peut suggérer une forme de fichier de reçu ou un contrôle qui échoue faute de graine. Il ne doit pas décider que le reçu est optionnel, ni qualifier de fiable une relance verte. Le choix reste une responsabilité d'équipe.
Mettre le budget de relance hors du test
La règle retenue dans l'exemple est volontairement simple : le processus de test exécute le contrôle une seule fois. Si l'équipe veut quand même relancer, elle le fait via une commande séparée, avec un budget écrit et un reçu pour chaque tentative.
Un reçu minimal peut consigner :
- la graine utilisée pour préparer les données ;
- la commande exécutée et le numéro de tentative ;
- le code de sortie ;
- une empreinte cryptographique de la sortie ;
- éventuellement un extrait de sortie, après vérification qu'il ne contient aucune donnée sensible.
Le but n'est pas d'inventer un système d'observabilité. Il s'agit surtout de rendre visible le fait qu'il y a eu plusieurs essais, et d'éviter qu'un résultat « de seconde main » passe pour un succès obtenu au premier lancement.
Exemple de script de reçu
Le script ci-dessous est un exemple pour un dépôt de travail local. Il n'a pas été exécuté sur un service réel pour cet article. Enregistrez-le sous retry-receipt.mjs pour utiliser await au niveau supérieur, ou configurez le projet en mode module.
// retry-receipt.mjs - exemple proposé, pas un journal de production
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
const budget = Number(process.env.RETRY_BUDGET || "1");
const seed = process.env.ORDER_SEED || "seed-not-set";
const cmd = process.env.CHECK_CMD || "node check-order.mjs";
function sha(text) {
return createHash("sha256").update(text).digest("hex");
}
function runOnce(attempt) {
return new Promise((resolve) => {
const child = spawn(cmd, {
shell: true,
env: { ...process.env, ORDER_SEED: seed, ATTEMPT: String(attempt) }
});
let out = "";
child.stdout.on("data", (chunk) => { out += chunk; });
child.stderr.on("data", (chunk) => { out += chunk; });
child.on("close", (code) => {
resolve({
attempt,
seed,
exit: code,
body_sha256: sha(out),
tail: out.slice(-240)
});
});
});
}
const attempts = [];
for (let i = 1; i <= budget; i += 1) {
attempts.push(await runOnce(i));
if (attempts.at(-1).exit === 0) break;
}
const receipt = {
decided: "retry_lives_outside_the_test",
budget,
seed,
attempts,
keep: attempts.some((row) => row.exit !== 0)
? "investigate_before_patch"
: "still_not_proof"
};
writeFileSync("retry-receipt.json", JSON.stringify(receipt, null, 2));
console.log(JSON.stringify({ path: "retry-receipt.json", keep: receipt.keep }));
Le champ keep est volontairement prudent. Même si toutes les tentatives autorisées réussissent, le résultat n'équivaut pas à une preuve que le comportement est correct. Un code de sortie 0 ne se suffit pas à lui-même.
Ce script reste une esquisse, pas un composant prêt à intégrer tel quel à une chaîne de déploiement. Il utilise shell: true, ce qui peut être risqué si la commande contient une entrée non fiable. Il faudrait aussi gérer les échecs de lancement du processus, valider que le budget est un entier positif, et réfléchir à la taille des sorties conservées. L'empreinte aide à comparer, mais elle ne remplace ni l'examen ni une trace exploitable.
Faire échouer le contrôle si la graine manque
La graine doit être exigée avant de compter un essai. Sinon, un second lancement peut être présenté comme une « comparaison », alors qu'il ne reproduit pas vraiment le même scénario.
// check-order.mjs - exemple de contrôle en exécution unique
const seed = process.env.ORDER_SEED;
if (!seed || seed === "seed-not-set") {
console.error("seed missing; refusing to score a rerun");
process.exit(2);
}
console.error(JSON.stringify({ seed, status: "picking" }));
process.exit(1);
Ici, le statut picking est codé en dur pour illustrer un échec contrôlé. Dans la version réelle, il faut remplacer ça par l'appel HTTP après avoir vérifié que le mécanisme de reçu marche bien sur ce scénario simple. Ajouter l'appel réel trop tôt recréerait le problème initial, avec plus de variables et moins de lisibilité.
Pour une exécution locale, les commandes peuvent ressembler à :
export ORDER_SEED="order-17-worker-not-run"
export RETRY_BUDGET="2"
export CHECK_CMD="node check-order.mjs"
node retry-receipt.mjs
env -i PATH="$PATH" HOME="$HOME" \
ORDER_SEED="$ORDER_SEED" RETRY_BUDGET=1 \
CHECK_CMD="node check-order.mjs" \
node retry-receipt.mjs
La première commande peut s'exécuter dans un environnement possiblement contaminé. La seconde lance un processus avec un environnement nettoyé et un budget de un. Elle sert de comparaison, pas de victoire.
Pour isoler vraiment les données, il ne suffit pas de nettoyer l'environnement : il faut aussi éviter le partage de la même base de données ou du même volume persistant.
Lire les résultats sans les surinterpréter
Un reçu n'est utile que si l'équipe sait quoi en faire. La règle de lecture proposée pour l'exemple :
- Graine absente : code 2. Le harnais refuse d'évaluer le scénario. Ce n'est pas un échec fonctionnel du produit.
- Budget de deux dans un environnement sale : un succès tardif reste une observation, pas une autorisation de modifier l'application.
- Budget de un dans un processus propre : la comparaison compte, à condition que les données soient aussi isolées.
- Échec propre sur
picking, mais succès au deuxième essai dans l'environnement sale : l'état résiduel devient une piste prioritaire. - Même échec dans les deux environnements : cherchez ensuite une étape worker manquante ou une autre cause reproductible, puis seulement après, envisagez un correctif.
- Reçu marqué
still_not_proof: ce n'est pas une approbation, même si le dernier code de sortie est 0. - Sortie contenant des secrets ou des données client : ne versionnez pas l'extrait. Conservez plutôt l'empreinte et les éléments expurgés nécessaires à l'enquête.
Un reçu ne remplace pas l'investigation
Deux exécutions ne mesurent pas une flakiness, et elles ne disent rien, à elles seules, sur la fréquence du défaut en production. Ici, on ne revendique aucune statistique : le reçu sert à garder une trace de décisions locales (scénario lancé, ordre des essais, résultat).
C'est particulièrement important avec des traitements asynchrones. Si le worker doit réessayer, cette politique doit être définie dans le contrat du worker : propriétaire, limite, alerte. Elle ne devrait pas être dissimulée dans expectPacked, où elle ressemble vite à une tolérance du test plutôt qu'à une règle produit.
Il existe aussi des limites à la méthode. Une session sur un serveur gratuit n'est pas un jumeau de la production. Quand des preuves réglementaires, des builds signés ou des garanties matérielles sont nécessaires, une exécution ponctuelle sur une infrastructure « facile » ne peut pas les remplacer. Et si vous partagez la base entre deux processus, vous reprovoquez précisément la contamination que vous cherchez à diagnostiquer.
Utiliser un assistant sans lui déléguer la décision
Dans le contexte de cet exemple, MonkeyCode est l'assistant mentionné pour cette démarche. L'opérateur indique proposer un accès gratuit à des modèles et une option de serveur gratuite. Ces informations viennent de l'opérateur ; elles ne constituent ni une évaluation indépendante, ni une garantie durable sur les conditions d'accès. Aucun détail précis (quota, taille de machine, durée) n'est avancé ici, car ces éléments peuvent évoluer.
Un usage raisonnable : demander à l'assistant de relire la structure du reçu et de signaler les champs qui contredisent les règles établies. La personne qui examine le code décide ensuite si elle garde ou non keep. Si la documentation du projet ne mentionne pas ces options, supprimez la référence au produit : la traçabilité fonctionne sans.
Divulgation : cet article a été préparé dans le cadre d'une démarche de présentation du produit MonkeyCode. Cette mention ne change pas les limites exposées plus haut et ne constitue pas un avis produit.
Pour structurer la revue de votre pipeline, consultez aussi les pratiques d'intégration continue et la sécurité des scripts d'automatisation.
Conclusion : garder la relance visible
Le point central n'est pas de choisir entre zéro et trois tentatives. Le vrai enjeu, c'est de séparer le contrôle du contrat de la politique de répétition. Une assertion qui relance en silence rend l'échec moins lisible, et elle peut faire passer un état résiduel pour une validation.
Commencez par exiger une graine, exécutez le test une seule fois, conservez le code de sortie et l'empreinte, puis comparez avec un processus réellement isolé. Ne modifiez l'application qu'après avoir trouvé une cause reproductible. Si le comportement asynchrone impose une relance, mettez cette règle dans le contrat du composant concerné, avec une limite et un propriétaire.
La prochaine fois qu'un test passe au second lancement, ne demandez pas seulement combien de fois le relancer. Demandez ce que le premier essai a réellement changé. Souvent, c'est là que commence le diagnostic utile.