100% code coverage : la course à l'échalote

Cet indicateur n'est pas très utile si l'on ne sait pas ce qu'on met derrière… Elle reste cependant interprétable essentiellement quand elle est faible !
Publié le 19 février 2025
100% de couverture de code est souvent affiché comme l'objectif à atteindre au sein des équipes. Qu'à cela ne tienne : il suffit de quelques tests end-to-end bien placés pour faire grimper en flèche cet indicateur, sans que la sécurité de l'application s'en trouve réellement améliorée. J'entends par là la capacité à refactorer agressivement la codebase sans risque majeur de régression, parce que vous disposez d'un vrai filet de sécurité.
L'injonction aux 100% de couverture de code est l'illustration parfaite de la loi de Loi de Goodhart  : "lorsqu'une mesure devient un objectif, elle cesse d'être une bonne mesure". Au final, mieux vaut parfois une couverture de code un peu moins bonne avec un bon choix de tests et une prise de risque mesurée. Pour cela, il faut connaître l'application en détail, c'est-à-dire ses fonctionnalités, et avant cela le métier.
Mais voyons au préalable ce que signifie 100% de couverture de code.

Un indicateur à prendre avec précautions

Considérons cet exemple :
const positive = (n: number): number => Math.max(0, n);

describe("A single test achieves 100% code coverage…", () => {
  test("n > 0", () => {
    expect(positive(1)).toEqual(1);
  });
});
Ici, la couverture en lignes de code est de 100%. Le code est-il bien testé pour autant ? À l'évidence non, parce que la fonction
Math.max
cache une structure de contrôle, donc 2 branches. Le cas des nombres négatifs n'est pas étudié. Comme il n'y a qu'un test pour 2 branches, la couverture en branches est de 50%.
Mais même cette couverture en branches n'est pas suffisante. Considérez l'exemple suivant :
const twist = (n: number): number => {
  let result = n;

  if (n < 0) {
    result *= -1;
  }

  if (n < 1) {
    result *= -1;
  }

  return result;
};

describe("Two tests achieve 100% code coverage…", () => {
  test("n < 0", () => {
    expect(twist(-2)).toEqual(-2);
  });

  test("n > 1", () => {
    expect(twist(2)).toEqual(2);
  });
});
Ici, la couverture en lignes de codes est de 100%, de même que la couverture en branches : chaque branche des 2 structures de contrôle est explorée. Sauf que les structures de contrôle sont couplées au travers de la variable
result
, qu'elles modifient conjointement. Il faudrait donc étudier ce qu'il se passe entre 0 et 1, en plus d'étudier spécifiquement ces valeurs limites.
Cela pour dire qu'une couverture de code à 100% est une condition nécessaire mais non suffisante. 100% de couverture de code ne signifie pas que votre code est bien testé, que vous possédez un réel filet de protection anti-régressions.

Le Test-driven development à la rescousse !

S'il n'est pas la solution à tous vos problèmes, le Test-driven development  (TDD) occupe malgré tout une place importante dans l'éventail des solutions. En effet, c'est l'outil idoine pour écrire du code testé exhaustivement, par construction (et bien sûr, il existe des contre-exemples, qui m'obligent à mentir par souci de simplification).
Cela se déduit de la première règle du TDD : "Vous n'êtes autorisé à écrire du code que si ce code sert à faire passer un test unitaire ayant préalablement échoué". Ainsi, lorsque vous rajoutez un nouveau chemin d'exécution, cela fait suite à l'écriture d'un test relatif à ce même chemin d'exécution.
Rappelons en guise d'évidence que le TDD se prête très bien à l'écriture du cœur du domaine d'une application, là où se concentrent les règles de gestion et donc la logique algorithmique. Mais il est peu probable que vous écriviez plus de 30% de la codebase de la sorte. Si tel est le cas, vous êtes peut-être déjà en proie aux affres de la loi de l'instrument .

Comment utiliser la couverture de code ?

Faut-il jeter la couverture de code ? Bien sûr que non : il faut la considérer pour ce qu'elle est, un indicateur. Voici ce qu'on peut en tirer :
  • Déjà, la couverture de code est interprétable quand elle est faible : si elle vaut 10%, vous devez écrire des tests (commencez par des tests unitaires).
  • Ensuite, on peut s'intéresser à sa variation plutôt qu'à sa valeur : elle devrait toujours augmenter ; si elle diminue, c'est qu'il y a un problème.
  • Enfin, nous l'avons vu, une couverture de code de 100% ne suffit pas. On ne peut pas l'interpréter, il faut aller plus loin et ajouter de l'intelligence, c'est-à-dire adopter une vraie stratégie de test . Le test ultime restant bien sûr les tests de mutation, qui sont les tests des tests.