80 ou 90% de couverture de tests pour un nouveau projet?

S'il est une question qui revient très souvent lorsqu'on parle de tests c'est le taux de couverture que les projets doivent atteindre. Il est même maintenant courant de voir des demandes de 80% de taux de couverture dans les definitions of done ou même dans les contrats.

Ces seuils sont mis en place pour apporter de la qualité aux projets, c'est en tout cas l'intention initiale et elle est tout à fait louable. Malheureusement, le taux de couverture, utilisé ici comme un objectif et non plus comme un indicateur devient un piètre moteur pour l'amélioration de la qualité du code.

Dans cet article je vais partager mon point de vue de développeur sur les dérives de l'utilisation de cette metric mais aussi, et surtout, proposer des alternatives pour répondre à ce réel besoin : faire de meilleurs tests pour, au final, délivrer de meilleures solutions.

Cet article s'adresse aux développeurs mais aussi aux chefs de projets, scrum masters, commerciaux et à toutes les personnes prenant part aux projets sans faire de code.

À quoi servent les tests ?

Avant de nous lancer dans l'écriture de meilleurs tests et dans le jugement de leur qualité il convient de rappeler leur utilité. Il existe de nombreux types de tests chacun servant un dessein différent. Une représentation très pratique pour en comprendre certains est le quadrant des tests agiles :

Ce schéma se lit sur deux axes, ainsi les tests unitaires sont en rapport avec la technologie et aident au développement. Dans la majorité des cas, lorsqu'on parle de taux de couverture on parle des tests de la partie gauche qui vont inclure :

  • Les tests unitaires : Une unité fonctionelle isolée a-t-elle le comportement attendu ?
  • Les tests sociaux : Deux éléments fortement couplés ont-ils le comportement attendu ?
  • Les tests d'intégrations : Mon code s'intègre-t-il correctement avec des composants externes (Framework, persistence, WebServices, …) ?
  • Les tests de contrats : Une API ayant fonctionné fonctionne-t-elle toujours correctement ?
  • Les tests de composants : La logique métier de mon composant isolé du reste du système est-elle correcte ?
  • Les tests end-to-end : Mon système a-t-il le comportement attendu ?

Lors de l'écriture de chaque test il faut donc s'assurer que l'on répond à un besoin en utilisant le bon type de test et les bons outils. Utiliser un marteau pour planter des vis entraînera, au mieux, des tests inutiles et, au pire, des tests compliqués à maintenir qui vont réduire la vélocité.

On rencontre ici un premier biais à l'utilisation d'un objectif de couverture : fixer un objectif de couverture arbitraire va pousser les développeurs à faire des tests pour respecter ce taux, pas forcément pour répondre à un besoin.

Même si la majorité du temps on reste dans le cadre de la loi de Goodhart en détournant une mesure pour en faire un objectif. Dans certaines situations extrêmes l'objectif va être détourné avec des développeurs qui vont pousser du code et des tests parfaitement inutiles pour augmenter artificiellement le taux de couverture. On parle alors d’effet cobra.

Au même titre que tout autre code les tests doivent être faits s'ils apportent de la valeur que ce soit pour la fabrication du code ou pour en assurer la pérennité.

Doit-on tout tester ?

Fixer un taux de couverture arbitraire sur un projet implique qu'à terme il faudra tout tester, ou presque.

Il existe cependant au moins deux cas où faire des tests a très peu de sens :

  • Lorsque des éléments d'un Framework (ou du langage) rendent les tests très coûteux à mettre en place. Ce coût peut venir de l'effort nécessaire pour pouvoir faire le test ou du temps d'exécution des tests. Si ce coût est bien plus élevé que la valeur du test alors sa mise en place n'a pas de sens. On rencontre généralement ce cas pour la gestion de certaines erreurs levées par des composants externes qui sont difficilement simulables et dont le code de gestion est, au final, très simple ;
  • Lorsque le code que l'on veut tester a été généré, que ce soit par notre IDE ou par un autre outil. Par exemple vouloir tester une méthode equals générée automatiquement va rapidement demander beaucoup de temps et de cas de tests pour une valeur très faible.

Faire du code c'est faire preuve de pragmatisme : les efforts déployés doivent être en cohérence avec la valeur produite. Ainsi passer une heure pour tester des POJO générés n'a pas vraiment de valeur mais utiliser cette même heure pour s'assurer que l'on traite correctement les erreurs venant de la base de données (par exemple) en aura beaucoup plus.

Dans cette optique, si on admet qu'il n'est pas intéressant ou utile de tester tout le code on va rapidement se retrouver dans une impasse en imposant un taux de couverture minimal.

Que montre le taux de couverture ?

Imposer un taux de couverture minimal implique de comprendre cet indicateur, ce qui n'est pas si simple... On distingue plusieurs types de taux de couverture, en voici quelques-uns :

  • La couverture de lignes : les tests sont lancés, chaque ligne sur laquelle un test passe est considérée comme couverte ;
  • La couverture de branches : les tests sont lancés, chaque branche du code dans laquelle un test passe est considérée comme couverte. Par exemple, un "if(titi && toto)" contient 4 branches (false && false, false && true, true && false, true && true) mais la première et la seconde sont identiques en terme d'exécution ;
  • La couverture de mutation : Un changement est fait dans le code (changement d'une condition, d'une valeur, …) et on lance les tests passants dans le code modifié. Si les tests passent encore (dans ce cas on dit qu'ils survivent) alors le cas n'est pas couvert.

Quand on parle de taux de couverture on parle des deux premiers types : dans quelles parties de mon code passent mes tests ?

Si on prend le célèbre kata GildedRose voici un test en Java avec 100% de couverture de lignes et de branches :

public class GildedRoseTest {
  @Test
  public void testGildedRose() {
    GildedRose rose = new GildedRose(defaultItems());
    
    for (int day = 0; day < 50; day++) {
      rose.updateQuality();
    }
  }
  
  private Item[] defaultItems() {
    return new Item[] { new Item("+5 Dexterity Vest", 10, 20),
    new Item("Aged Brie", 2, 0),
    new Item("Elixir of the Mongoose", 5, 7),
    new Item("Sulfuras, Hand of Ragnaros", 0, 80),
    new Item("Sulfuras, Hand of Ragnaros", -1, 80),
    new Item("Backstage passes to a TAFKAL80ETC concert", 15, 20),
    new Item("Backstage passes to a TAFKAL80ETC concert", 10, 49),
    new Item("Backstage passes to a TAFKAL80ETC concert", 5, 49) };
  }
}

En regardant le taux de couverture on va donc penser que l'on peut modifier sans risque la logique en ayant l'assurance d'avoir un test en erreur en cas de régression. Malheureusement, ce test ne valide quasiment rien (uniquement l'absence d'exceptions dans le traitement).

Certes ici le problème est évident ! Malheureusement, dans des cas plus complexes, il est beaucoup plus compliqué de détecter ce genre de défaut pour un humain. On s'attend donc à avoir cette information avec la couverture de tests mais ici seule la couverture de mutations détecte le problème.

On pourrait se dire qu'il faut alors se baser sur la couverture de mutation pour savoir si l’on teste vraiment des choses. Attention cependant : la couverture de mutation est très coûteuse (chaque test étant rapidement lancé plusieurs centaines de fois) et il est donc souvent contre-productif de vouloir la généraliser.

La couverture de mutation ne va aussi valider qu'un nombre fini de mutations, elle n'assure pas non plus une couverture complète !

Mettre en place une analyse systématique de la couverture de mutation sur des éléments métiers critiques est cependant une bonne idée.

Bien qu'étant un indicateur intéressant le taux de couverture ne permet pas de mesurer :

  • La pertinence des tests : comme nous l'avons vu, il est possible de faire des tests qui ne testent rien mais il est aussi tout à fait possible de faire des tests qui n'ont aucune valeur (alors qu'ils valident effectivement des comportements) ;
  • La testabilité de la solution : au prix de beaucoup d'efforts il est toujours possible de passer dans du code avec des tests. Ce n'est pas parce que des tests passent dans du code que ce code est testable ;
  • La pérennité des tests : des tests sur une solution qui n'était, au final, pas testable vont rapidement ralentir la vélocité en n'apportant aucune qualité. On va alors se retrouver dans des situations de suppression ou de désactivation des tests car, effectivement, ils n'apportent rien et ne sont plus pertinents pour la solution ;
  • La qualité des tests : De manière générale le taux de couverture ne donne aucune indication sur la qualité des tests qui ont été écrits. Ce n'est pas le but de cet indicateur et il ne doit pas être utilisé pour juger cela !

Ignorer la couverture

Paragraphe à destination des techniciens !

Accepter de ne pas tout tester doit se faire de manière consciente et les classes et methods que l'on choisit de ne pas tester ne doivent pas polluer nos rapports de couverture.

Il est courant de voir des classes ou des packages ignorés dans les analyses de couverture en utilisant la propriété sonar.coverage.exclusions avec le sonar-maven-plugin dans nos pom.xml. Malheureusement, à l'heure où j'écris ces lignes, il n'est pas encore possible d'ignorer des methods de cette manière (même si cela devrait se faire de cette manière à terme).

Il est cependant possible d'ignorer l’analyse de couverture sur des éléments ayant une annotation particulière ! Pour ce faire il faut commencer par créer une annotation runtime dans notre projet et mettre Generated dans le nom de cette annotation :

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Generated {
}

Vous pouvez nommer cette annotation IdeGeneratedForMyProject si vous le souhaitez. Il faut simplement qu'il y ait Generated dans son nom et qu'elle soit en rétention RUNTIME.

Il faut ensuite configurer l'analyse de couverture de manière plutôt classique dans notre pom.xml :

<project xmlns="http://maven.apache.org/POM/4.0.0" 
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
    http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

[...]

  <properties>
  [...]
    <sonar.core.codeCoveragePlugin>jacoco</sonar.core.codeCoveragePlugin>
<sonar.jacoco.reportPath>${project.basedir}/../target/jacoco.exec</sonar.jacoco.reportPath>
    <sonar.language>java</sonar.language>

    <jacoco.version>0.8.4</jacoco.version>
    <surefire.version>2.22.2</surefire.version>
  </properties>

  <build>
    <plugins>
      [...]
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>${surefire.version}</version>
      </plugin>

      <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>${jacoco.version}</version>
        <configuration>
          <append>true</append>
        </configuration>
        <executions>
          <execution>
            <goals>
              <goal>prepare-agent</goal>
            </goals>
          </execution>
          <execution>
            <id>post-unit-test</id>
            <phase>test</phase>
            <goals>
              <goal>report</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

De cette manière les classes et methods que vous allez annoter seront exclues des calculs de taux de couverture et il sera alors beaucoup plus aisé d'exploiter les rapports.

On voit aussi souvent l'utilisation de commentaires pour exclure du calcul de couverture certaines portions de code. Je n'aime pas vraiment cette manière de faire : je veux connaitre le code que j'ai fait et qui n'est pas couvert ! Même lorsque ce code n'est pas testable je veux garder en tête qu'un refactoring est nécessaire sur cette portion de la solution.

Quelles solutions ?

La première chose à faire est d'utiliser ce taux de couverture pour ce qu'il est : un indicateur. Utilisé comme tel, il a beaucoup de valeur pour les développeurs que ce soit pendant l'écriture des tests ou tout au long de la vie du produit. Pendant l'écriture il permet de s'assurer :

  • Que l'on n’a pas oublié un cas essentiel dans nos tests ;
  • Que les cas qui ne sont pas testés le sont de manière consciente et admise.

Et pendant la vie du produit il est intéressant de suivre la tendance de ce taux et non pas sa valeur absolue ! Et c'est bien là un premier élément de réponse : bloquer du code lorsqu'il a moins de XX% de couverture va entraîner de nombreux biais mais bloquer du code si la couverture est moins bonne que précédemment s'inscrit dans une démarche d'amélioration continue. Même si le nouveau code produit n'est pas testable il sera toujours possible d'ajouter des tests ailleurs. Enfin, tant qu'on se concentre sur le code ou l'on veut calculer la couverture en ignorant le code généré.

Ce n'est cependant qu'une potentielle réponse (qui ne sera pas adaptée dans tous les contextes) à une toute petite partie du problème : le point essentiel ici étant de pouvoir juger de la qualité des tests qui sont faits sur la solution.

Pour juger de leur qualité il est primordial d'évaluer leur valeur :

  • Les tests ne doivent pas baisser la vélocité ; ainsi des phrases telles que "Je n'ai pas fait les tests, je n'ai pas eu le temps" sont des indicateurs très forts d'un problème plus profond. De la même manière une baisse générale de la vélocité dans le temps est certainement un signe de manque de qualité ;
  • Les tests ne doivent pas gêner le quotidien sans raison : "Les tests sont en erreur mais c'est normal" est une remarque qu'il est essentiel de traiter au plus tôt ;
  • Les tests doivent apporter une forte assurance dans la qualité de la solution. Si vos tests passent, la solution fonctionne, ça ne veut pas dire qu'elle fait ce qu'elle doit faire, simplement qu'elle fait ce que vous attendez qu'elle fasse. Si votre produit plante régulièrement alors que tous les tests sont au vert vous avez un problème.

Ce sont là 3 indicateurs (le ressenti de l'équipe étant un indicateur très important à prendre en compte) qui vous donneront une bien meilleure vision de l'état de vos tests.

Si vous détectez ce type de situation il existe de nombreuses solutions pour apporter de la valeur à vos tests et pour apprendre à bien vivre avec eux au quotidien !

La bonne méthode pour le bon besoin

Un premier point essentiel à prendre en compte est, comme toujours, d'utiliser des outils et méthodes adaptés au besoin. Les tests répondent principalement à deux besoins :

  • Ils permettent l'émergence de designs offrant une réponse adaptée ;
  • Ils valident que l'application fonctionne de la manière définie par les tests. Dans tous les cas ils ne valident pas réellement le bon fonctionnement de l'application puisqu'il est très facile de faire, en toute bonne foi, des tests qui ne valident pas le bon comportement (mais c'est un autre sujet).

Même si le premier point est essentiel ce n'est pas le sujet de cet article :). En ce qui concerne la validation du fonctionnement il faut utiliser les tests pour ce pour quoi ils sont faits :

Valider Tests Méthode Définit par
L'implémentation technique dans le détail (gestion de chaque erreur etc…) Unitaires et sociaux TDD Développeur
L'intégration de nos composants avec des composants externes D'intégration TDD Développeur
L'intégration des composants externes avec nos composants De contrats TDD Développeur ou fonctionnel
La réponse au métier De composant
End-to-end
ATDD Fonctionnel
La robustesse de la solution De propriétés Développeur et fonctionnel

Bien évidement toute personne impliquée sur le projet doit participer aux tests je n'indique ici que les acteurs principaux de chaque famille de test.

Il n'y a pas de choix à faire ici, chacun de ces types de tests doit être mis en place pour permettre une couverture complète et donc une validation de la solution.

L'équilibre pour maintenir la valeur des tests

Réussir à équilibrer les tests est sans doute plus compliqué que faire les tests en eux même. En fait, lorsqu'un projet rencontre des problèmes de fiabilité la tendance est souvent d'ajouter des tests end-to-end sans ajouter d'autres types de test. Dans des cas extrêmes les campagnes end-to-end peuvent même :

  • Prendre plusieurs jours pour être jouées ;
  • Demander de très nombreuses d'heures d'analyses humaine pour supprimer les faux positifs ;
  • Demander du travail à plein temps pour être maintenues.

Bref, des campagnes qui auront coûté des fortunes et qui ne seront pas utilisées. Des tests ne sont utiles que s'ils apportent du confort dans le quotidien de tout le monde et pour ce faire la pyramide de tests doit être équilibrée :

Une pyramide de tests mal équilibrée générera plus de frustration et de perte de temps qu'elle ne sera bénéfique à l'équipe et à la solution !

Les tests ne sont pas la solution à un manque de fiabilité

Nous arrivons maintenant à la conclusion de cet article et je crains qu'elle ne déplaise…

En fait, en demandant un taux de couverture de tests minimal on cherche à fiabiliser nos solutions. Malheureusement, s'ils n'existent pas, les tests ne seront pas la solution à un manque de fiabilité.

Je ne suis pas en train de dire que les tests ne valident pas le fonctionnement. Ce que je veux dire ici c'est que forcer la rédaction de tests de quelque manière que ce soit n'améliorera en rien la fiabilité d'une solution.

Faire des tests apportant de la valeur est tout sauf une tâche aisée, dans une équipe qui ne rédige pas de tests forcer leur réalisation n'apportera probablement aucune valeur.

La solution ici n'est pas de demander X tests supplémentaires mais d'accompagner l'équipe pour que les méthodes évoquées plus haut (TDD et ATDD) deviennent naturelles et qu'il ne soit plus envisageable de travailler d'une autre manière.

Bien sûr cela prendra du temps et de l'énergie. Dans certains cas même ce ne sera pas possible mais dans tous les cas le jeu en vaut la chandelle ! Une équipe qui fait naturellement des tests avant de faire son code alliera vélocité et qualité des développements tout en apportant une réponse vraiment adaptée au besoin.

Il existe bien des manières d'accompagner une équipe dans cette transformation en commençant par intégrer un tech-lead rompu à ces pratiques mais, dans tous les cas, imposer 80% de couverture dans les contrats n'améliorera pas la fiabilité de la solution.