Mutation testing en Java

Les tests unitaires sont un élément très important du développement logiciel : ils permettent de détecter des régressions très tôt dans le processus de développement.

Cette importance rend vitale la possibilité de vérifier facilement que les tests unitaires sont correctement rédigés. Plusieurs pistes s’offrent aux équipes de développement pour le vérifier, parmi lequel les outils vérifiant la couverture de code.

Les limites de la couverture de code

Les outils de vérification de couverture de code, tels que Cobertura ou JaCoCo, permettent de mesurer la proportion du code source parcourue lors de l’exécution des tests unitaires. On saura donc ligne par ligne, instruction par instruction, branche par branche, quel code est effectivement couvert par les tests unitaires. Ces outils permettent donc de se rendre compte rapidement de certains oublis, par exemple si l’on oublie de couvrir une condition d’un « if ».
Une mauvaise couverture de code indique donc généralement que les tests unitaires posent problème, parce qu’une partie importante du code n’est pas du tout couverte. Mais par contre, une bonne couverture de code n’indique pas forcément que les tests unitaires sont pertinents. On peut très bien couvrir 100% du code sans vraiment tester que ce code fait ce qu’on veut. Voici un exemple très (très) basique pour s’en convaincre :

Une classe « Document » :

public class Document {
        
        private String title;
        private Integer id;
        private String content;

        //... getters et setters

}

Une classe « DocumentService », qui crée un document en fonction des paramètres qui lui sont passés :

public class DocumentService {
        public Document buildDocument(Integer id, String title, String content) {
                Document document = new Document();
                document.setId(id);
                document.setTitle(title);
                document.setContent(content);
                return document;
        }
}

Et un test unitaire pour tester la méthode buildDocument :

public class DocumentServiceTest {
        
        private DocumentService documentService = new DocumentService();

        @Test
        public void testBuildDocument() {
                Document document = documentService.buildDocument(10, "my title", "my content");
                
                assertThat(document).isNotNull();
        }
}

(assertThat est une instruction AssertJ)

J’avais prévenu, l’exemple est vraiment très basique (stupide, même), mais il permet de se rendre compte facilement du problème. Si je fais passer cobertura sur ce test unitaire, j’obtiens une couverture de code (pour les lignes, il n’y a pas de branche) de 100% pour ma classe DocumentService. C’est normal : à partir du moment où j’entre dans ma fonction buildDocument, du moment qu’aucune exception ne se produit, je passerai sur toutes les lignes de la fonction. Cobertura fait donc bien son travail. Mais en fait, on n’a quasiment rien vérifié : on a vérifié que la fonction nous renvoie un résultat non null. Donc si on regarde bien la fonction buildDocument, on a vérifié sa première et sa dernière ligne. Les 3 lignes qui initialisent les propriétés du document n’ont pas été vérifiées : on peut les enlever, le test passe toujours. On voit donc bien ici que le taux de couverture du code a ses limites : il vérifie la quantité de code testé, mais ne teste pas vraiment la pertinence des tests unitaires.

C’est là qu’entre en scène le « mutation testing ».

Palier ces limites : le « mutation testing »

Mutation testing, késako ?

Le mutation testing, c’est un peu bourrin : il s’agit grosso modo de modifier nos classes (on parle de mutations), puis de faire passer les tests unitaires sur ces classes modifiées, et de regarder si les tests unitaires passent toujours. Si les tests unitaires passent toujours, c’est qu’il y a un problème. Concrètement, l’outil de mutation testing va aller modifier le bytecode pour changer une instruction « + » en « -« , ou bien enlever un appel de fonction, etc.. Normalement, ce genre de mutations devrait faire planter les tests s’ils sont pertinents. Par exemple, j’ai la fonction suivante :

public int addition(int a, int b) {
  return a + b;
}

Si je change « a + b » en « a – b », alors ça devrait faire planter les tests unitaires. C’est ça que l’outil de mutation testing vérifie.

PIT

PIT est un outil de mutation testing pour java. Il propose un plugin maven (il y a même un plugin sonar non officiel), et semble plutôt performant, sachant tout de même que le mutation testing, c’est coûteux.
Si je reprends l’exemple que j’avais donné plus haut du DocumentService, et que j’utilise PIT dessus, voici ce que ça donne pour DocumentService :

On voit donc bien que PIT nous indique que DocumentService a une couverture de code de 100%, c’est donc cohérent avec ce que cobertura rapportait. Mais par contre, PIT nous indique que notre classe a un « mutation coverage » de seulement 25%. Et si l’on clique sur les détails, voici ce qu’on peut voir :

PIT nous surligne donc en rouge les 3 lignes dont j’avais dit qu’elles n’étaient pas du tout testées, et en dessous, il nous indique ce qui ne lui va pas : quand il enlève ces instructions dans le bytecode, le test unitaire passe toujours. Si maintenant, je modifie mon test unitaire pour qu’il ressemble à ça :

@Test
public void testBuildDocument() {
        Document document = documentService.buildDocument(10, "my title", "my content");
        
        assertThat(document).isNotNull();
        assertThat(document.getContent()).isEqualTo("my content");
        assertThat(document.getId()).isEqualTo(10);
        assertThat(document.getTitle()).isEqualTo("my title");
}

Alors j’obtiens un « mutation coverage » de 100% pour ma classe DocumentService.

Conclusion

Un outil de mutation testing permet de palier les limites d’un outil de couverture de code, parce qu’il permet de vérifier la pertinence des tests unitaires. Le seul ennui, c’est que le mutation testing est coûteux ; la FAQ de PIT indique d’ailleurs : « Mutation testing is a computationally expensive process and can take quite some time depending on the size of your codebase and the quality and speed of your test suite. PIT is fast compared to other mutation testing systems, but that can still mean that things will take a while. ».

Voici un projet maven comprenant le code que j’ai montré dans l’article. Pour générer un rapport PIT, il suffit de lancer la commande suivante :
mvn compile org.pitest:pitest-maven:mutationCoverage
Un rapport HTML est alors généré dans le dossier target/pit-reports.

Vus : 1705
Publié par mael : 17