J'ai récemment eu un débat avec des collègues au sujet de la couverture de code. Personnellement je ne suis pas partisan d'un suivi fort de la couverture de code, d'autres n'utilise pas cet indicateur, d'autres sont partisans du 100% de couverture (ou presque 100%).
Disclaimer : je vais exposer mon avis, vous avez totalement le droit de faire autrement, ce n'est pas à moi de vous dire comment travailler.
La couverture de code, c'est quoi ?
Commençons par le commencement, le code coverage, ou en bon français couverture de code, correspond au pourcentage de code qui est exécuté via un ensemble de test (souvent tests unitaires mais parfois aussi les tests d'intégrations, et très rarement les tests bout en bout). On est sur un pourcentage qui correspond au rapport entre la quantité de code qui est exécuté pendant l'exécution des tests face à la quantité totale de code.
Vous remarquez que je ne vous ai pas dit de quoi je parlais vraiment quand je parle de quantité. En fait quand on parle de couverture on parle souvent en ligne de code utile (toutes les lignes des fichiers, lignes vide et lignes de marqueurs (parenthèses, accolades, etc.) exclues). Mais on voit aussi la couverture de code par statement (instruction) qui correspond au nombre d'instruction exécuté, ou encore branches qui correspond au nombre de branche de l'arbre d'exécution qui serait exécuté, on voit aussi la couverture de code par fonction qui correspond au nombre de fonction qui sont testés par rapport au nombre total de fonction dans l'application.
Exemple de couverture de code
Prenons un exemple :
function foo(a) {
if (a === 42 || a === -42) {
return -42;
}
return a;
}
Pour couvrir complètement ces quatres lignes de code il faut :
- 2 tests pour avoir une couverture ligne à ligne (foo(1) et foo(42))
- 2 tests aussi pour une couverture en branche (foo(1) et foo(42))
- 3 tests aussi pour une couverture en statement (foo(1), foo(42), foo(-42))
- 1 test pour une couverture par fonction (foo(1))
Remarquez aussi que je n'ai jamais parlé d'assertion. En effet il suffit d'un morceau de code soit exécuté pour être couvert. Donc ce test suffit à couvrir du code :
it('basic test without assertion', () => {
foo(1);
});
La couverture de code est un indicateur de code non testé, rien de plus
Au vu de ce que je viens d'expliquer, on peut dire que la couverture de code ne permet pas de valider qu'un code est couvert par les tests. En effet comme les assertions ne sont pas prises en compte par la couverture de code, on peut très bien avoir une très bonne couverture de code en ne testant rien du tout, car un test sans assertion est un test qui sera passant quoi qu'il arrive (sauf une exception, mais un try catch qui ne fait rien suffit à garder le test vert sans se poser de question).
Si on suit uniquement l'indicateur de couverture de code, on est donc obligé de faire aveuglément confiance dans les développeurs qui vont les écrire. La qualité des tests repose donc sur les développeurs.
À l'inverse, une couverture de code très bas indique un manque de test. Car test de qualité ou non, si un code n'est pas couvert c'est certain qu'il n'y a aucun test dessus.
Une bonne couverture ? Une couverture basse ? Une couverture haute ?
On entend très souvent des gens parler de "bon coverage", de "coverage trop bas" ou "très haut". Concrètement ça veut dire quoi ?
Disons qu'on est sur un projet avec 80% de couverture. Vous pensez que c'est bien ? Pas mal ? Et si je vous dis que ces 80% sont atteint avec 10-20 tests seulement avec uniquement des tests très grossiers sur les contrôleurs de l'application backend en question avec un mock de la couche d'accès aux données ? Là tout de suite pas sûr que ce soit aussi pertinent.
À l'inverse si je vous dis que je suis face à un projet avec une couverture de 40-50% ? Bien ? Pas bien ? Bof ? Et si là c'est un projet front avec 50% du code sous la forme de composant qui ne font que de l'affichage en s'appuyant sur un store Redux contenant toutes les règles métiers, testés avec 1500 tests et la couverture de code des éléments du store est proche de 100% ? Mieux ? Bon je relance une quarantaine de tests bout en bout qui valident la plupart des cas métiers en manipulant les composants depuis un navigateur ?
Et si je vous dis que je suis face à un projet java springboot couvert à 100% avec juste les POJO d'exclus car généré via l'IDE ? Oups petite erreur de configuration, en fait seul quelques POJO sont inclus dans la couverture de code et donc on a 100% sur quelques classes et non toute l'application…
Tout ça pour dire que ça dépend des cas. Ce n'est pas un indicateur suffisant pour dire si une application est correctement testée et si la couverture de code est bonne ou mauvaise.
Un objectif pour la qualité du code ?
Très souvent on utilise des outils comme SonarQube et la Quality Gate pour savoir à partir de quel seuil on considère qu'on a bien testé une application. En soi ce n'est pas une mauvaise chose de faire ça. Par contre ça ne doit pas devenir un objectif en tant que tel.
Pour moi si la couverture de code devient un objectif on perd en productivité et on risque de faire des trucs absurdes juste pour une valeur chiffrée (par exemple laisser volontairement des parties de code non testé pour avoir du code à tester quand la couverture sera trop basse…).
Personnellement je suis plus intéressé par le fait de voir effectivement quand je travaille sur un projet des tests unitaires cassés quand je change quelque chose ou même voir la tendance de couverture qui est en hausse ou en descente. Dans le meilleur des mondes, si on change une règle métier il doit y avoir au moins un test qui casse. De même on devrait toujours avec une couverture qui augmente.
Et si on testait nos tests ?
Quitte à se dire qu'on veut tester notre code autant tester aussi les tests non ? En effet si on veut pouvoir se reposer sur les tests pour valider notre code métier, il faut avoir confiance dans les tests. Vous me direz qu'écrire des tests pour tester les tests c'est se lancer dans une chaîne sans fin et vous n'aurez pas tort. Du coup je vous propose une technique qui vous permettra de tester vos tests sans écrire de ligne de code, il s'agit du mutation testing.
Le mutation testing fonctionne en partant d'un principe simple : si les tests sont bons, alors changer du code (changer la condition d'un if, changer une valeur initiale, etc.) dans l'application doit casser au moins un test. Du coup les outils de mutation testing vont prendre votre code, changer un morceau de code, lancer les tests et voir si ça casse : si au moins un test case, le mutant est dit capturé, sinon il est libre et un rapport d'exécution nous dira qu'on a un mutant libre ainsi que ce qui compose le mutant.
Dans l'idée c'est très bien. J'aime beaucoup l'idée. Mais il y a deux très grands mais : lancer une campagne de mutation testing est très long et plus la base de code est grande plus l'exécution est longue ; il faut une base de test qui s'exécutent très vite pour que ce soit exploitable.
À mon avis c'est un outil très intéressant à lancer par exemple une fois par semaine en se réservant une ou deux heures pour analyser le rapport.
Conclusion
Je ne vous dis pas de jeter votre SonarQube, ni de ne plus vous occuper du tout de la couverture. Juste essayé de prendre en compte d'autres aspects qui complète bien la couverture de code : la tendance de cette couverture (à la hausse, à la baisse, stable), le temps d'exécution des tests (si on a beaucoup de tests mais qu'il faut un temps extrêmement long pour les exécuter, on les jouera peu et donc ils seront peu utiles), la confiance de l'équipe dans les tests, etc.
La couverture est un indicateur comme on en trouve beaucoup d'autres, mais clairement pas le seul à suivre si on veut avoir des tests utiles.