Les tests à l'ère de l'IA : utiliser l'IA pour écrire des tests
Les tests générés par IA peuvent faire monter la couverture sans trouver de bugs. Utilisez des prompts fondés sur les exigences, la mutation et l examen des mocks.
Un test dont la valeur attendue a été produite en exécutant le code testé ne peut que confirmer ce que le code fait déjà ; si le bug est déjà présent, le test le verrouille.
Si vous générez des tests avec un assistant, le scénario vous est peut-être familier : la PR ajoute quarante tests, la couverture gagne deux points, la CI est verte, et le même bug de facturation atteint quand même la production le jeudi. Les tests n’ont jamais eu le moyen d’être en désaccord avec le code.
Cet article couvre les trois formes que prennent les tests générés lorsqu’ils passent sans rien protéger, et les trois vérifications qui les corrigent : prompter à partir de l’exigence plutôt que de l’implémentation, casser le code volontairement, et lire les mocks avant les assertions.
Points clés
- La couverture de lignes enregistre quelles lignes ont été exécutées et n’enregistre rien sur le fait qu’une assertion échouerait si ces lignes produisaient un résultat erroné.
- Une valeur attendue calculée à la main et une valeur copiée depuis la sortie de la fonction paraissent identiques dans un diff ; seule leur provenance diffère, et c’est pourquoi les tests générés passent la revue.
- Demander à un modèle d’écrire des tests pour un nom de fonction ne lui donne que l’implémentation comme point d’appui ; fournir les règles métier lui donne une source de vérité capable d’être en désaccord avec le code.
- La vérification la plus rapide sur une suite générée consiste à modifier une constante ou à inverser une comparaison dans le code source et à relancer les tests ; si tout reste vert, rien ne protégeait cette ligne.
- Si un test remplace par un mock la fonction qu’il prétend tester, son assertion vérifie la valeur de retour configurée du mock, et le test doit être supprimé plutôt que réparé.
Pourquoi la couverture augmente-t-elle alors que des bugs continuent de partir en production ?
La couverture mesure l’exécution, pas la vérification. Les fournisseurs de couverture de Jest (Babel/Istanbul par défaut, V8 en option) et ceux de Vitest (V8 par défaut, Istanbul en option) rapportent les mêmes quatre métriques : instructions, branches, fonctions et lignes. Aucun n’évalue si une assertion pourrait échouer. Un test qui appelle une fonction et fait une assertion sur ce qui est revenu couvre chaque ligne qu’il a traversée, exactement comme le ferait un test doté d’une assertion correcte.
C’est pourquoi le chiffre continue de grimper alors que les bugs continuent de partir en production. Les tests générés sont peu coûteux, donc davantage de code est exécuté sous test. Mais un test dérivé de l’implémentation partage chacun de ses défauts avec l’implémentation, et le rapport de couverture n’a aucune colonne pour cela.
Échec n° 1 : le test affirme ce que le code retourne déjà
Un modèle auquel on ne fournit que le code source dispose d’un seul oracle pour les valeurs attendues : le code source. Il raisonne sur la fonction (ou l’exécute), observe la sortie, et écrit cette sortie comme littéral dans l’assertion. Si la fonction est fausse, le littéral est faux de la même manière.
Prenons une fonction de remise contenant un bug de copier-coller :
// discount.ts
export function calculateDiscount(price: number, tier: "silver" | "gold"): number {
const rate = tier === "gold" ? 0.25 : 0.25; // bug: silver should be 0.15
return price * (1 - rate);
}
L’API describe/it/expect ci-dessous fonctionne à la fois avec Jest et Vitest ; Jest l’expose globalement, tandis que Vitest exige un import ou globals: true.
import { calculateDiscount } from "./discount";
// Generated from the implementation. 75 is what the buggy function returned.
it("applies silver discount", () => {
expect(calculateDiscount(100, "silver")).toBe(75);
});
// Written from the rule: silver is 15% off, so 100 * 0.85.
it("charges 85 for a 100 silver order", () => {
expect(calculateDiscount(100, "silver")).toBe(85);
});
Le premier test passe face à la fonction boguée. Le second échoue, et c’est précisément l’intérêt. Dans un diff, 75 et 85 paraissent tout aussi légitimes l’un que l’autre. Rien dans la syntaxe n’indique à un relecteur que l’un a été calculé à partir de la règle de tarification et l’autre copié depuis la sortie de la fonction. La seule défense consiste à demander d’où vient le chiffre.
Échec n° 2 : les tests générés ne couvrent que le chemin heureux
Une suite générée teste ce que le prompt a décrit, et un prompt qui nomme une fonction ne décrit que son fonctionnement normal. Les cas qu’une suite générée omet le plus souvent (entrée malformée, dépendance inaccessible, dépassement de délai) sont ceux que le prompt n’a jamais mentionnés, et le modèle n’avait donc aucune raison de les écrire.
Le résultat : cinq tests quasi identiques pour des entrées bien formées avec des paliers valides, et aucun pour un prix négatif, une chaîne de palier inconnue, ou un service en amont qui ne répond jamais. Ce sont ces chemins qui atteignent la production sans avoir été testés, car ce sont aussi ceux que les développeurs exercent le moins à la main.
Échec n° 3 : l’unité testée est mockée
Si un test remplace par un mock la fonction qu’il prétend tester, son assertion vérifie la valeur de retour configurée du mock plutôt que le comportement de la fonction. Les modèles mockent de façon agressive parce que le mocking fait passer les tests de manière fiable, et un test qui mocke la base de données, le client réseau et le service testé passera avec n’importe quelle implémentation.
Le schéma est plus facile à voir avec une dépendance injectée, ce qui évite à l’exemple les différences entre jest.mock et vi.mock :
// cart.ts
import type { calculateDiscount } from "./discount";
export function cartTotal(price: number, tier: "silver" | "gold",
discount: typeof calculateDiscount): number {
return Math.round(discount(price, tier) * 100) / 100;
}
// cart.test.ts
import { cartTotal } from "./cart";
// Before: the fake is the thing being checked.
it("returns discounted total", () => {
const fake = () => 85;
expect(cartTotal(100, "silver", fake)).toBe(85);
});
// After: assert on what cartTotal did, using a fake that exposes it.
it("rounds the discounted price to cents", () => {
const fake = () => 85.004999;
expect(cartTotal(100, "silver", fake)).toBe(85);
});
Le test « avant » passerait encore si cartTotal ignorait ses arguments et retournait 85. Le test « après » transmet au faux une valeur qui ne ressort correctement que si la logique d’arrondi s’exécute : il observe donc la fonction plutôt que le stub.
Correctif n° 1 : donnez à la génération de tests unitaires par IA l’exigence, pas le code
Demander à un modèle d’écrire des tests pour un nom de fonction ne lui donne que l’implémentation comme point d’appui. Fournir les règles métier sous forme d’exigences écrites lui apporte quelque chose que le code ne peut pas fournir : une spécification capable d’être en désaccord avec le code.
Le prompt faible :
Write unit tests for calculateDiscount.
Le prompt plus solide :
Write unit tests for calculateDiscount in discount.ts.
Rules:
- Silver tier is 15% off the price.
- Gold tier is 25% off the price.
- Price must be non-negative; a negative price throws.
- The result is rounded to two decimal places.
Compute every expected value from these rules, not from the
current implementation. Include at least one invalid-input case
per rule. Name each test as the rule it checks.
Le second prompt produit expect(calculateDiscount(100, "silver")).toBe(85) parce que 85 est ce que dit la règle, et il échoue face à la fonction boguée dès la première exécution. Des noms de tests qui se lisent comme des règles (« charges 85 for a 100 silver order ») rendent également la suite relisible comme une spécification. Le prompt ne garantit pas que le modèle ignore le code source ; il lui donne une source de vérité qui prime sur celui-ci.
Correctif n° 2 : cassez le code et guettez le rouge
La vérification la plus rapide sur une suite de tests générée consiste à modifier une constante ou à inverser une comparaison dans le code testé, puis à relancer les tests. Si tout reste vert, la suite ne protégeait pas ce code.
1. Fix the bug in discount.ts (silver rate to 0.15), then change 0.15 to 0.05.
2. Run your test command.
3. Expected: "charges 85" fails with received 95.
If nothing fails, no test asserts the silver rate.
Les tests de mutation automatisent cette démarche. StrykerJS (@stryker-mutator/core) injecte de petites modifications dans le code source, relance la suite pour chacune d’elles, et rapporte un score de mutation, qui divise les mutants attrapés par vos tests (un test a échoué, ou l’exécution a dépassé le délai) par le nombre de mutants valides. Ses mutateurs pris en charge inversent les opérateurs de comparaison, nient les booléens, échangent les opérateurs arithmétiques, vident les littéraux de chaîne et suppriment les corps de bloc. Des plugins d’exécution existent pour Jest et pour Vitest ; vérifiez la compatibilité du plugin avec la version que vous avez installée avant de l’adopter.
Correctif n° 3 : lisez les mocks avant les assertions
Relisez le diff d’un test généré dans cet ordre : ce qui est simulé, d’où viennent les valeurs attendues, puis ce qui est affirmé. Ce qui est simulé détermine ce que le test peut observer. L’origine de la valeur attendue détermine si l’assertion peut être en désaccord avec le code. L’assertion elle-même est la ligne la moins informative.
- Un mock qui se substitue à l’unité testée : supprimez le test.
- Une valeur attendue qui est un appel de fonction, ou un littéral sans règle derrière lui : recalculez-la depuis l’exigence.
- Des mocks uniquement pour les véritables frontières (base de données, réseau, horloge) et des assertions sur la sortie propre de l’unité : conservez-le.
Gardez le jugement
L’IA est fiable sur les parties mécaniques du test : fixtures, setup et teardown, tables paramétrées de cas quasi identiques, boilerplate pour les chemins d’erreur asynchrones. Elle n’est pas fiable lorsqu’il s’agit de décider quels comportements méritent d’être affirmés, car cette décision réside dans l’exigence, pas dans le code. Générez l’échafaudage, puis prenez le test le plus important du diff, cassez la ligne qu’il est censé protéger, et vérifiez qu’il passe au rouge avant de merger.
FAQ
Les tests de mutation remplacent-ils la couverture de code ?
Non. Les deux rapportent des lacunes différentes. La couverture vous indique quelles lignes n'ont jamais été exécutées par aucun test ; les tests de mutation vous indiquent quelles lignes exécutées ne sont protégées par aucune assertion. StrykerJS rapporte les deux : un mutant situé dans du code non exécuté reçoit l'état No coverage, tandis qu'un mutant situé dans du code exécuté pour lequel tous les tests passent encore est Survived. Le score de mutation compte les deux comme non détectés, donc une faible couverture fait directement baisser le score.
StrykerJS fonctionne-t-il avec Vitest et Jest ?
Oui, via des plugins d'exécution. Pour Vitest, installez @stryker-mutator/vitest-runner et définissez testRunner sur vitest dans la configuration de Stryker ; le plugin est livré sans Vitest lui-même, consultez donc son package.json pour connaître la version de Vitest la plus ancienne qu'il accepte. Les tests exécutés via le Browser Mode de Vitest sortent du périmètre pris en charge par le runner. Pour Jest, utilisez @stryker-mutator/jest-runner. Avec le runner Vitest, Stryker désactive la collecte de couverture propre à Vitest et fait en sorte que chaque exécution de mutant s'arrête au premier test qui échoue, car un seul échec suffit à tuer le mutant.
Comment repérer les tests générés par IA qui ne contiennent aucune assertion ?
Faites en sorte que le framework fasse échouer tout test comportant zéro assertion. Dans Vitest, définissez expect.requireAssertions dans la configuration ou passez --expect.requireAssertions en CLI ; un test qui se termine sans appeler expect échoue. Dans Jest, appelez expect.hasAssertions() dans le corps du test, ou depuis un hook beforeEach pour l'appliquer partout. Aucun de ces réglages ne vérifie si une assertion pourrait échouer, donc un test qui fait une assertion sur la valeur de retour d'un mock passe malgré tout.
Peut-on faire confiance aux tests de snapshot générés par IA ?
Pas sans relecture. Un snapshot enregistré à partir de la sortie actuelle affirme uniquement que le code continue de produire ce qu'il produisait au moment de l'enregistrement, bugs inclus, ce qui constitue la même tautologie qu'un littéral copié. Le réenregistrement n'est qu'à un flag de distance : jest -u et vitest -u réécrivent tous les snapshots en échec. En CI, le flag --ci de Jest fait échouer la build sur les nouveaux snapshots au lieu de les écrire silencieusement. Relisez les diffs de snapshot par rapport à l'exigence, pas par rapport à la sortie précédente.