← Retour au blog
Craftsmanship

Le piège du "Code Coverage Driven Development"

Pourquoi viser 100% de code coverage comme objectif principal est un piège : faux climat de confiance, tests inutiles, et alternatives comme le mutation testing et le TDD.

📅 ✍️ Antoine Coulon
code-coveragetestingtddmutation-testing

« On vise 100% de coverage pour s’assurer que notre code est correctement et suffisamment testé. » Cette phrase, on l’entend dans à peu près toutes les équipes, et elle part d’une excellente intention. Mais elle cache un raccourci dangereux : confondre la quantité de code exécuté par les tests avec la qualité de ce qui est réellement vérifié. C’est tout le problème de ce que j’appelle le Code Coverage Driven Development : faire du taux de couverture l’objectif premier plutôt qu’un simple indicateur parmi d’autres.

Le code coverage est une métrique intéressante. Mais quand 100% devient la cible à atteindre coûte que coûte, on encourage souvent l’écriture de tests incomplets, peu pertinents, voire totalement inutiles. Et la conséquence est paradoxale : on construit un faux climat de sécurité et de confiance, précisément là où l’on cherchait à se rassurer.

Ce que mesure (et ne mesure pas) le code coverage

Le code coverage répond à une seule question : quelles lignes (ou branches, ou instructions) de mon code ont été exécutées pendant le passage des tests ? C’est utile, parce qu’une portion de code jamais traversée par aucun test est une portion dont vous ne savez littéralement rien. À ce titre, la couverture est excellente pour repérer les angles morts : le catch qu’aucun scénario ne déclenche, la branche else oubliée, le module entier que personne ne sollicite.

Mais c’est là que s’arrête sa promesse. Le code coverage mesure l’exécution, pas la vérification. Qu’une ligne soit traversée ne dit rien de la pertinence de ce que vous en faites. Un test peut appeler une fonction, parcourir 100% de ses lignes, et ne contenir aucune assertion sérieuse sur son comportement. Considérez cet exemple :

function applyDiscount(price: number, percent: number): number {
  if (percent < 0 || percent > 100) {
    throw new RangeError("percent must be between 0 and 100");
  }
  return price - (price * percent) / 100;
}

// Un test qui atteint 100% de couverture de lignes…
it("applies a discount", () => {
  applyDiscount(100, 20);
  applyDiscount(100, -5); // attendu : lève une erreur
  expect(true).toBe(true); // …mais ne vérifie strictement rien
});

Ce test exécute chaque ligne de applyDiscount, branche d’erreur comprise. Votre outil de couverture affichera fièrement 100%. Pourtant, vous pourriez remplacer price - (price * percent) / 100 par price + (price * percent) / 100, ou supprimer entièrement le throw, et ce test continuerait de passer. La couverture est verte, et le code est faux. C’est exactement le faux climat de confiance que l’on cherche à éviter.

Le piège : optimiser la métrique au lieu de l’objectif

Le problème n’est pas la métrique en soi, c’est ce qui arrive quand on en fait une cible. C’est une illustration parfaite de la loi de Goodhart : lorsqu’une mesure devient un objectif, elle cesse d’être une bonne mesure.

Dès qu’une équipe est sommée d’atteindre 90% ou 100% de couverture, le comportement rationnel, sous pression de temps, n’est plus d’écrire de bons tests, mais d’écrire des tests qui font monter le chiffre. On voit alors apparaître :

Le taux grimpe, le tableau de bord vire au vert, et le management est rassuré. Mais la capacité réelle de la suite de tests à détecter une régression, elle, n’a pas bougé. Pire : ce vert généralisé décourage de regarder de plus près, parce que « tout est couvert ». On a transformé un outil de diagnostic en décoration.

100% n’est pas un gage de qualité

Il faut le dire clairement : 100% de couverture ne prouve pas que votre code est correct, ni même qu’il est bien testé. Cela prouve seulement que chaque ligne a été exécutée au moins une fois. À l’inverse, une suite à 75% de couverture composée de tests rigoureux, ciblés sur les comportements critiques et truffée d’assertions précises, vous protégera infiniment mieux qu’une suite à 100% remplie de tests creux. La couverture est un plancher utile, pas un plafond rassurant.

Test-First ou Test-Last : quand écrit-on les tests ?

Au-delà de la métrique, le piège du coverage est souvent symptomatique d’un problème de moment : à quel instant écrit-on les tests ?

Le Test-Last consiste à écrire le code d’abord, puis les tests ensuite. C’est l’approche la plus répandue, et c’est aussi celle qui mène le plus directement au Code Coverage Driven Development. Pourquoi ? Parce qu’une fois le code écrit et fonctionnel, les tests deviennent une corvée a posteriori, dont le seul objectif tangible et mesurable est… le taux de couverture. On écrit alors des tests pour couvrir le code existant, en épousant sa forme, ses raccourcis et ses bugs éventuels, au lieu de vérifier un comportement attendu de manière indépendante.

Le Test-First, à l’inverse, consiste à écrire le test avant le code de production. Le test exprime une intention, une spécification exécutable, avant que l’implémentation n’existe. Ce simple renversement change tout : le test ne décrit plus ce que le code fait, mais ce qu’il devrait faire. La couverture devient alors une conséquence naturelle d’une démarche saine, et non plus l’objectif que l’on poursuit.

Le TDD : la couverture comme effet de bord

Le Test-Driven Development est la forme disciplinée du Test-First. Il repose sur un cycle court et répété, souvent résumé par trois temps :

  1. Red : écrire un test qui échoue, décrivant un comportement attendu qui n’existe pas encore.
  2. Green : écrire le minimum de code de production pour faire passer ce test.
  3. Refactor : améliorer la structure du code, en restant vert.
// 1. RED : le comportement n'existe pas encore, le test échoue
it("rejects a percent above 100", () => {
  expect(() => applyDiscount(100, 150)).toThrow(RangeError);
});

// 2. GREEN : on écrit juste ce qu'il faut pour passer au vert
function applyDiscount(price: number, percent: number): number {
  if (percent < 0 || percent > 100) {
    throw new RangeError("percent must be between 0 and 100");
  }
  return price - (price * percent) / 100;
}

// 3. REFACTOR : on nettoie sans casser le test

La vertu fondamentale du TDD, c’est qu’aucune ligne de code de production n’existe sans une raison de test préalable. Chaque branche, chaque condition, chaque ligne a été motivée par un test qui la réclamait. Résultat : une couverture élevée n’est plus un objectif que l’on traque, mais un effet de bord mécanique de la méthode. Et surtout, ce sont des tests qui vérifient réellement quelque chose, puisqu’ils ont été écrits pour échouer en l’absence du comportement attendu.

Le TDD inverse donc complètement le rapport au coverage : on ne court pas après le chiffre, le chiffre vient à nous, accompagné, cette fois, d’une vraie garantie.

Le mutation testing : tester vos tests

Reste une question qui hante tout ce raisonnement : comment savoir si mes tests valent réellement quelque chose ? La couverture ne peut pas y répondre, par construction. C’est précisément ce que résout le mutation testing.

Le principe est aussi simple qu’astucieux. Un outil de mutation testing introduit délibérément de petites modifications dans votre code de production, des mutants, puis relance votre suite de tests sur chaque version mutée. Par exemple :

Pour chaque mutant, deux issues possibles :

// Code original
function isAdult(age: number): boolean {
  return age >= 18;
}

// Mutant introduit par l'outil : >= devient >
function isAdult(age: number): boolean {
  return age > 18; // un mineur de 18 ans serait désormais "adulte"
}

Si aucun de vos tests ne vérifie précisément le cas limite age === 18, ce mutant survit, alors même que votre couverture de lignes sur cette fonction est à 100%. Le mutation testing met le doigt exactement là où le code coverage est aveugle : sur la pertinence des assertions, pas seulement sur l’exécution.

Le mutation score (pourcentage de mutants tués) est ainsi une mesure bien plus honnête de la qualité d’une suite de tests que le simple taux de couverture. Dans l’écosystème JavaScript/TypeScript, Stryker est l’outil de référence ; il existe des équivalents matures dans la plupart des langages (PIT pour Java, mutmut pour Python, etc.).

npx stryker run
# ...
# Mutation score: 100.00 (killed: 42, survived: 0, timeout: 1)

Le mutation testing a un coût : il est plus lent que l’exécution d’une suite classique, puisqu’il relance les tests pour chaque mutant. On l’utilise donc plutôt ponctuellement, ou ciblé sur les modules critiques, que sur l’intégralité d’un gros projet à chaque commit. Mais comme outil de diagnostic de la vraie solidité de vos tests, il n’a pas d’équivalent.

Conclusion

Le code coverage n’est pas l’ennemi. C’est un indicateur utile, particulièrement pour débusquer le code que personne ne teste. Le piège, c’est d’en faire l’objectif plutôt qu’un symptôme : dès que 100% devient la cible, on optimise le chiffre et non la confiance, et l’on se retrouve avec un tableau de bord rassurant posé sur une suite de tests creuse.

La sortie de ce piège tient en deux mouvements. D’abord, changer le moment : écrire les tests d’abord, via le TDD, pour que la couverture devienne la conséquence naturelle d’une intention vérifiée, et non une corvée a posteriori. Ensuite, changer la mesure : utiliser le mutation testing pour évaluer ce que vos tests valent réellement, là où la couverture reste muette.

Visez de bons tests, et la couverture suivra. L’inverse n’est presque jamais vrai.