On a tous déjà vu ce badge vert « Coverage: 100% ». Il est rassurant, il fait plaisir aux managers, et il donne une sensation de devoir accompli.
Mais que signifie-t-il vraiment ?
En tant qu’ingénieur qualité, je vois souvent une confusion entre « code exécuté » et « code testé ». La couverture de code (ou code coverage) ne vous dit pas si votre code fonctionne correctement. Elle vous dit seulement quelles lignes ont été traversées par vos tests… et surtout, lesquelles ne l’ont pas été.
Décortiquons ensemble les quatre colonnes les plus classiques d’un rapport pour comprendre où se cache la vraie valeur.
Prenons une fonction simple mais avec un peu de logique : un calculateur de réduction.
export function calculatePriceWithDiscount(price: number, isMember: boolean) {
let discount = 0; // Ligne 2
if (isMember) { // Ligne 3
if (price > 100) { // Ligne 4
discount = 20; // Ligne 5
} else { // Ligne 6
discount = 10; // Ligne 7
}
}
return price - discount; // Ligne 10
}discount.ts
Si nous lançons un test unique : calculatePriceWithDiscount(50, false), voici le rapport que le terminal nous renvoie :
-----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files | 50.00 | 25.00 | 100.00 | 50.00 |
discount.ts | 50.00 | 25.00 | 100.00 | 50.00 | 4-7
-----------------|---------|----------|---------|---------|-------------------
Note sur les exemples
Les exemples de code que nous utilisons sont risibles et simplifiés pour des raisons purement pédagogiques : montrer le fonctionnement interne de la couverture.
Dans un contexte professionnel, de vrais tests devraient inclure une analyse rigoureuse des limites et des domaines (boundary testing). Cela signifie vérifier les cas extrêmes, les plafonds légaux, les planchers (par exemple, un prix ne peut pas être négatif), et la gestion des entrées invalides ou non attendues.
Analysons ces colonnes une par une pour comprendre pourquoi nous avons ces scores.
1. Couverture des fonctions (function coverage)
Question principale : « La fonction a-t-elle été appelée au moins une fois ? »
C’est la métrique la plus basique. Puisque notre test appelle calculatePriceWithDiscount, la fonction est marquée comme « vue ».
Verdict : Est-ce que mon code est fiable ? Absolument pas. C’est une métrique « macro » qui sert surtout à repérer le code mort (dead code) ou les fichiers oubliés.
2. Couverture des instructions (statement coverage)
Question principale : « Chaque instruction a-t-elle été exécutée ? »
Une « instruction » (statement) est une unité logique d’exécution que le moteur JavaScript doit traiter. Comptons-les précisément dans notre code, il y en a exactement 6 :
let discount = 0;(Initialisation)if (isMember) { ... }(La structure conditionnelle principale)if (price > 100) { ... }(La structure conditionnelle imbriquée)discount = 20;(L’affectation du cas > 100)discount = 10;(L’affectation du cas else)return ...;(Le retour)
Notre test calculatePriceWithDiscount(50, false) parcourt le chemin suivant :
- Il exécute la N°1 (
let discount...). - Il exécute la N°2 (
if isMember). Comme c’est faux, il saute tout le bloc interne. - Il exécute la N°6 (
return).
Bilan : Nous avons exécuté les instructions 1, 2 et 6. Les instructions 3, 4 et 5 n’ont jamais été atteintes.
Verdict : 3 sur 6 = 50%.
3. Couverture des lignes (line coverage)
Question principale : « La ligne de texte du fichier a-t-elle été touchée ? »
C’est ici que la confusion règne souvent. Dans un projet bien formaté (avec Prettier), la couverture des lignes est souvent identique à la couverture des instructions. Mais la différence est notable si votre formatage est compact.
Imaginez cette ligne de code compacte :
if (isMember) console.log("User is member");
C’est une seule ligne, mais deux instructions (le if et le console.log).
Si mon test est isMember = false :
- Le moteur lit la ligne pour évaluer le
if. -> Couverture des lignes : 100% (La ligne est touchée). - Le moteur n’exécute PAS le
console.log. -> Couverture des instructions : 50%.
En résumé : La couverture des instructions est la vérité machine. La couverture des lignes est la vérité humaine (visuelle). Si vous utilisez un linter strict qui force « une instruction par ligne », ces deux métriques seront synchronisées.
4. Couverture des branches (branch coverage)
Question principale : « A-t-on testé chaque embranchement décisionnel possible ? »
C’est ici que se joue la robustesse. Notre code contient des conditions (if), ce qui crée des embranchements.
Comptons les embranchements possibles :
if (isMember): Peut être VRAI ou FAUX. (2 embranchements)if (price > 100): Peut être VRAI ou FAUX. (2 embranchements)
Visualisons le flux pour bien comprendre les 4 embranchements :
Total mathématique : 4 issues possibles (les 4 flèches qui sortent des losanges).
Avec notre test calculatePriceWithDiscount(50, false), nous n’avons pris qu’un seul embranchement : la flèche « Non (Embranchement C) » tout en haut. Nous n’avons jamais exploré les autres cas.
Note : L’imbrication crée une dépendance logique. Si vous testez les embranchements A (discount = 20) ou B (discount = 10), vous validez mécaniquement et implicitement le passage par l’embranchement isMember = true.
Verdict : 1 embranchement testé sur 4 = 25%.
Le lien avec la complexité cyclomatique
C’est ici que la couverture des branches devient un véritable outil d’architecture. Ce nombre d’embranchements indépendants (ici 4) correspond à la complexité cyclomatique de votre fonction.
Face à une complexité élevée, le réflexe de l’ingénieur ne doit pas être d’ajouter aveuglément des tests, mais de s’interroger sur la structure du code :
- Réduire d’abord (refactoring) : Il est toujours plus rentable de simplifier le code que de complexifier les tests. Si une fonction a trop d’embranchements, découpez-la. Mieux vaut deux fonctions simples faciles à valider qu’une « usine à gaz » maintenue en vie par des tests illisibles.
- Couvrir ensuite : Une fois la logique épurée et ramenée à l’essentiel, la couverture des branches devient impérative pour garantir que chaque scénario restant est maîtrisé.
Le piège de la couverture
Attention. Il est possible d’avoir 100% partout sans rien tester. Regardez :
test("full coverage", () => {
calculatePriceWithDiscount(150, true); // Embranchement A (voir diagramme)
calculatePriceWithDiscount(50, true); // Embranchement B
calculatePriceWithDiscount(50, false); // Embranchement C
});discount.test.ts
Les métriques passent au vert. Tout est à 100%. Pourtant, je n’ai fait aucune assertion (expect(...)).
Si demain je change discount = 20 par discount = 0 (un bug), mes tests passeront toujours et ma couverture restera à 100%.
Conclusion
La couverture de code est un indicateur technique précieux pour repérer le code mort ou les zones d’ombre, mais le véritable filet de sécurité réside dans la couverture fonctionnelle.
L’objectif n’est pas de chasser le pourcentage, mais de s’assurer que chaque règle métier et chaque cas d’usage critique est validé par un test. Si cette exigence fonctionnelle est remplie, alors votre couverture de code sera mécaniquement élevée et, surtout, pertinente.
Note : Des pratiques comme le TDD (Test Driven Development) favorisent naturellement cette approche en garantissant que chaque ligne de code produite répond à un besoin fonctionnel testé, éliminant de facto le code mort. Mais c’est un sujet pour un autre article.