Ton IA écrit des tests qui ne peuvent pas échouer

Tu demandes des tests à l'IA. Elle t'en pond douze, tous verts. La CI passe au vert. Tu merges. Trois jours plus tard, un bug part en prod, sur une fonction que ces tests étaient censés couvrir. Tu rouvres le fichier de test, et là tu comprends : il s'exécutait, il passait, mais il ne testait rien.

Un test vert n'est pas une preuve. C'est une hypothèse. Et l'IA, livrée à elle-même, est très douée pour écrire des hypothèses qui ne risquent jamais d'être démenties.

Le test fantôme

Prends une fonction toute bête, une remise au-dessus de 100 euros :

func Discount(total int) int {
    if total > 100 {
        return total - 10
    }
    return total
}

Voici un test comme l'IA en produit quand tu lui demandes « écris-moi un test pour ça » sans plus de cadre :

func TestDiscount(t *testing.T) {
    got := Discount(150)
    if got < 0 {
        t.Errorf("le résultat ne doit pas être négatif")
    }
}

Ce test est vert. Il exécute bien la branche de la remise (donc ta couverture grimpe). Mais regarde l'assertion : got < 0 n'est jamais vraie, quoi que fasse Discount. Tu peux remplacer total - 10 par total + 10, par total * 2, par 42 : le test reste vert. Il ne vérifie pas le comportement, il vérifie que la lumière est allumée.

La couverture ne mesure pas ce que tu crois

Le piège, c'est que ce test fantôme gonfle ta couverture. Le coverage compte les lignes exécutées, pas les assertions qui mordent. Une ligne traversée par un test qui n'affirme rien d'utile compte autant qu'une ligne réellement vérifiée. Du coup un rapport de couverture à 90 % peut cacher une moitié de tests qui ne tomberont jamais, même si tu casses le code exprès.

C'est exactement le terrain de jeu d'un LLM. Son signal de récompense, c'est « les tests passent ». Pas « les tests attrapent un bug ». Sans oracle externe pour l'en empêcher, il dérive vers le chemin le plus court vers le vert : des assertions molles, des mocks qui se testent eux-mêmes, des cas qui n'exercent jamais la branche risquée.

Le red-check : casse le code, exige le rouge

La parade tient en une manipulation, et elle est vieille comme le TDD : avant de faire confiance à un test, vérifie qu'il sait échouer. Mute la ligne qu'il est censé protéger, relance, et attends-toi à le voir rougir. S'il reste vert, il est vacant.

Sur notre fonction, je remplace une seconde la remise :

// mutation temporaire : - devient +
return total + 10

Le test fantôme reste vert. Verdict : à la poubelle. Voici celui qui mérite ta confiance :

func TestDiscount(t *testing.T) {
    if got := Discount(150); got != 140 {
        t.Errorf("Discount(150) = %d, attendu 140", got)
    }
}

Avec la même mutation, Discount(150) renvoie 160, le test rougit immédiatement. Il mord. C'est ça, un test : pas un qui passe, un qui sait pourquoi il passerait moins.

Automatiser le red-check : le mutation testing

Faire ça à la main sur chaque test ne tient pas. C'est précisément ce qu'automatise le mutation testing : l'outil applique des centaines de petites mutations à ton code (un > qui devient >=, un + qui devient -, un return vidé) et relance ta suite après chacune. Chaque mutation qui ne fait rougir aucun test est un mutant survivant : un trou que tes tests ne voient pas.

En Go, gremlins fait le travail :

go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
gremlins unleash ./...

Il te sort un mutation score : le pourcentage de mutants tués. Là où la couverture te dit « cette ligne est traversée », le score de mutation te dit « cette ligne est réellement testée ». Les deux chiffres n'ont rien à voir, et c'est le second qui compte.

Comment je le câble dans une boucle IA

Quand je laisse un agent écrire du code et ses tests, je ne le laisse pas se déclarer satisfait. Avant toute relecture, un gate objectif tourne : build, lint, suite de tests, puis un red-check sur les tests critiques. L'agent mute lui-même la ligne cible, vérifie que le test rougit, restaure. Un test resté vert après mutation est réécrit, pas négocié. Le LLM n'a pas son mot à dire sur la question « est-ce que ça teste vraiment » : la mutation tranche, lui ne fait que constater.

La règle qui en découle est simple : aucun test généré n'entre dans la suite sans avoir prouvé qu'il sait échouer. Le coût est minime, le retour énorme, parce qu'un test vacant est pire qu'une absence de test. L'absence, tu la vois. Le vacant, lui, t'endort.

Conclusion

On a appris à se méfier du code écrit par une IA, et on relit. On accorde encore une confiance aveugle aux tests qu'elle écrit, parce qu'ils sont verts. Mais le vert ne se démontre pas tout seul : un test n'a de valeur que par le rouge qu'il est capable de produire. Tant que tu n'as pas vu un test échouer au moins une fois, tu n'as pas un test, tu as une décoration.

Commentaires (0)