Cet article est un extrait du livre sur JUnit
La couverture de code est une mesure qui permet d’identifier la proportion du code testé. Les outils de couverture permettent de réaliser cette mesure, d’identifier les parties de code non couvertes et de les visualiser.
Cette mesure peut être calculée de différentes manières. Pour illustrer ces différents modes de calculs, nous utiliserons le code suivant :
public class CompteBancaire { protected String numeroDeCompte; public CompteBancaire(String numeroDeCompte) { this.numeroDeCompte = numeroDeCompte; } public String interrogerCompte(String motdepasse) { String resultat = null; if (motdepasse.equals("madatedenaissance")) { Integer solde = calculDuSolde(); resultat = "Information du compte:"; resultat += numeroDeCompte; resultat += ".\r\n"; resultat += "Votre compte est créditeur de "; resultat += solde.toString(); resultat += "€"; } else { gererErreurDeMotDePasse(); } return resultat; } protected Integer calculDuSolde() { Integer solde = new Integer(0); solde = 1000; solde += 100; solde -= 200; solde += 400; solde -= 300; return solde; } protected void gererErreurDeMotDePasse() { throw new RuntimeException("Erreur non gérée correctement"); } }
Nous testerons cette classe à l’aide du test suivant :
public class CompteBancaireTest { @Test public void testInterrogerCompte() { CompteBancaire compte = new CompteBancaire("0123456789"); String verif = "Information du compte:0123456789.\r\n"; verifi += "Votre compte est créditeur de 1000€"; assertEquals( verif, compte.interrogerCompte("madatedenaissance")); } }
La classe testée contient 20 lignes de code exécutables, 4 méthodes et une condition, soit 2 chemins logiques possibles.
Nous pouvons alors calculer différentes métriques de couverture.
Couverture linéaire
La couverture en lignes de code indique la proportion des lignes de code exercées lors du test. Elle est la plus répandue et la plus simple à visualiser : chaque ligne compte pour un élément. Dans cet exemple, 18 lignes sur 20 sont couvertes. La couverture en lignes sera donc de 18/20=90 %.
La couverture en méthodes calcule la proportion des méthodes couvertes au moins une fois. Chaque méthode compte pour un. Ainsi dans notre exemple, sur les 4 méthodes présentes, 3 sont testées. La couverture sera donc de 75 %. Cette mesure ressemble à la précédente avec une granularité plus large.
Couverture conditionnelle
La couverture conditionnelle calcule la proportion des chemins logiques couverts. C’est la mesure la plus compliquée à appréhender car elle change notre vision du code : au lieu d’en avoir une vision linéaire sous forme de listing, elle pousse vers une vision arborescente du code. Chaque condition compte pour deux. Pour couvrir la condition à 100 %, il faut tester le cas où la condition est vraie puis celui où elle est fausse. Ainsi dans le cas d’une structure conditionnelle if/then/else, couvrir chaque branche compte pour un. Dans notre exemple, seul le cas then est couvert. La couverture de la classe est donc de 50 %.
Quelle mesure utiliser ?
La couverture en lignes de code est intéressante car très simple à appréhender. De plus, elle permet de facilement mettre en évidence les portions de code non testées par blocs.
Dans notre exemple, on peut constater que le cas d’erreur n’est pas du tout testé.
Cependant, il est facile d’être leurré par les chiffres remontés : en effet, les cas d’exécution normale (quand tout va bien) sont souvent ceux qui ont le plus de poids. Les cas d’erreurs sont en général bien plus légers. Ainsi, un code couvert à 90 % peut donner un faux sentiment de sécurité lorsque seul le cas nominal est testé.
C’est ce qu’illustre notre exemple : la gestion du cas d’erreur lève une exception système.
Il est même possible d’être complètement floué par cette mesure. Considérons le code suivant :
public class Secret { public String donnerSecret(String motdepasse) { String secret = null; if (motdepasse.equals("aliens")) { secret = " La vérité est ailleurs "; } return secret.trim(); } } Testons cette classe à l'aide du test suivant : public class SecretTest { @Test public void testDonnerSecret () { Secret message = new Secret(); String verification = "La vérité est ailleurs"; assertEquals(verification, message.donnerSecret("aliens")); } }
Ce test couvre à 100 % le code autant en ligne de code qu’en procédure.
Pourtant, un bug critique se cache manifestement dans le code… En effet, l’exécution du programme suivant fait planter l’application :
public class ApplicationConsole { public static void main(String[] args) { Secret messager = new Secret(); String message = messager.donnerSecret("motdepasseoublie"); System.out.println(message); } }
Exception in thread "main" java.lang.NullPointerException at Secret.donnerSecret(Secret.java:8) at ApplicationConsole.main(ApplicationConsole.java:4)
Ainsi, la couverture en ligne de code ne suffit pas pour donner une vision satisfaisante de la qualité intrinsèque du code.
La couverture conditionnelle est intéressante car elle donne une autre vision du code, en chemins d’exécutions possibles. Elle pondère différemment les proportions testées en partant du principe que chaque chemin a le même poids, indépendamment de sa longueur. Cependant, cette mesure ne permet pas d’apprécier la proportion de code testée. Ainsi, un code ne contenant que peu de chemins logiques, tous couverts, peut donner une fausse impression de sécurité. C’est pourquoi il faut balancer cette mesure avec une mesure en lignes de code ou en méthodes.
Ainsi, la mesure de la couverture de code sera une combinaison de différents modes de calcul. Chaque outil a son propre mode de calcul offrant le choix aux développeurs.
Cet article est un extrait du livre sur JUnit
Très bon article.
De mon point de vue, cela fait remonter un problème dans l’élaboration des tests unitaires : les cas nominaux sont très souvent testés (heureusement, mais je préfère mettre “très souvent” que toujours…malheureusement !) et on oublie, par contre là presque toujours, les cas “limite”.
Pour ce qui est du code non couvert, l’essentiel correspond à la mauvaise gestion des exceptions, d’une part ; et à leur mauvais tests, d’autre part.
Que ce soit les exceptions auxquels on s’attend (celles qui sont explicitement levées, définies dans la signature de la méthode à tester), celles auxquelles on pourrait s’attendre (l’exemple du NullPointerException dans votre exemple suscité est parlant)ou bien – et c’est souvent là qu’est la problématique – pour les exceptions inattendues (typiquement de type RuntimeException)…
On retombe, au final, sur la robustesse du code initial.
Les tests de NullPointerException (en particulier) sont à faire en amont, au niveau du code produit.
On peut faire tous les tests qu’on veut, si tant est que le code testé est un minimum “fiable”.
On ne devrait pas avoir d’exception autre que celles attendues.
Et il est vrai que, même si on tend à une couverture maximale en optimisant ses tests unitaires, cela n’empêche pas le “bon sens” programmatique.
D’ailleurs, si on part, dans la même perspective, sur une vision Test Driven Development (TDD), et que si on écrit le test unitaire AVANT le code, on se rend bien compte que le test unitaire va effectuer des contrôles techniques mais aussi fonctionnels ; ce sur quoi on va s’attarder dans nos tests d’ailleurs :
– que le comportement est celui attendu
– et/ou que la valeur renvoyée est celle attendue
– que l’exception générée est celle attendue en cas d’erreur (ce que je préconise toujours, il y a un travail de fond à faire là-dessus ; c’est l’histoire du 90/10, où dans 90% tout marche bien – les cas nominaux – mais ce sont les 10% autres qui posent souci…)
Le fait que le code initial produise un bug critique non attendu sort du scope du test, le développeur doit prendre conscience de la robustesse de son code dès sa production, et non espérer que les bugs soient tous détectés par la “magie” de JUnit 🙂
Merci pour ce retour.
En effet, les cas d’erreur sont rarement testés en détail, et c’est bien la beauté de la couverture: rendre toutes les parties non couvertes par les tests visible rapidement. Il faut cependant rester vigilant: couvert ne signifie pas testé pour autant!
Après, c’est au développeur d’analyser le rapport de couverture et affiner ses tests au besoin.
Tres clair et didactique.
Peut-on aller plus loin avec une analyse des expression ou du code pour demultiplier les branches ?
cas simple “if( a or b)” donne 4 branches.
Oui. Les outils de mesure de couverture conditionnelle font ce travail.
Ainsi ‘if (a or b)’ ne sera considéré couvert à 100% que si les quatre cas sont testés.