Estimer la qualité de vos suites de tests grâce au mutation testing

Laurent Fourmy-Mangin - Jun 16 '20 - - Dev Community

La couverture de code des tests unitaires est une métrique couramment suivi au sein des projets afin de déterminer la confiance en la qualité de la phase de test et par extension de celle du produit.

Un taux minimal arbitraire est même fixé dans de nombreuses stratégies de test d'entreprise, devenant ainsi souvent l'alpha et l'oméga de toute discussion sur la qualité entre l'équipe et le management.

On a une couverture de TU de 100% ? Il ne peut plus rien nous arriver d'affreux maintenant...

L'illusion de la sécurité


Malheureusement très souvent, peu de recul est pris sur la signification de cette métrique, encore moins sur son utilisation.

Non la couverture de code n'est pas inutile, elle permet de quantifier l'effort réalisé par l'équipe de développement sur la phase de test mais prise seule elle ne dit absolument rien de la qualité et de la pertinence de cet effort.

Pour illustrer cela, prenons l'exemple de cette méthode très simple, renvoyant vrai ou faux selon si l'âge fournit en entrée est majeur ou non.

const estMajeur = (age) => age >= 18


Afin d'obtenir une couverture de test de 100%, un test unitaire est ajouté :

describe("Majorité", () => {
    test("Devrait dire lorsqu'un age est majeur", () => {
      expect(estMajeur(35)).toBe(true)
  });
});


Nous avons donc une couverture de 100%, et tout est vert.

Tous les tests sont ok avec une couverture à 100%

Pour autant, il est assez évident que notre "suite" de tests est beaucoup trop faible en l'état. Aucun test n'est réalisé pour un âge inférieur à 18 ans, encore moins pour l'âge de 18 ans pile...

Kill'em all !


C'est ici qu'intervient le mutation testing dont le but va être de qualifier la qualité de notre suite de tests de façon plus précise.

L'idée est d'introduire de petits changements dans le code (mutations) puis d'exécuter à nouveau la suite de tests. Si notre suite est de bonne qualité, une majorité des mutations devrait être détectée (killed) par la phase de test.

Les bénéfices alors sont multiples :

  • Identifier les morceaux de code dont les tests sont trop faibles - là où les mutations ne sont pas tuées par la suite de tests,
  • Identifier les tests trop faibles - ceux qui ne tuent jamais de mutations,
  • Obtenir un score de mutation qui conjointement à la couverture de code donnera une vision beaucoup plus précise de la qualité. ​ ### Théorie ​ Pour cela, plusieurs concepts sont introduits: ​
  • Opérateur de mutation ou Mutation Operator - un opérateur de mutation est un changement appliqué au code original. Par exemple, modifier un > par un < ou and par or dans une condition.
  • Mutant - un mutant est la version modifiée de l'entité originale (par exemple une classe ou un module), à laquelle a donc été appliqué un opérateur de mutation.
  • Mutations tuées / survivantes - lors de l'exécution des tests sur un mutant (i.e. le code modifié), deux résultats sont possibles :
    • Au moins un test a échoué et a donc détecté la mutation, le mutant est alors considéré tué ;
    • Aucun test n'a échoué, le mutant a donc survécu.
  • Mutations équivalentes - parfois une mutation n'est pas "pertinente" car ne déclenchant pas de comportement "déviant", par exemple:
    • Mutations dans du code mort / inutile
    • Mutations n'affectant que les performances
    • Mutations n'impactant que l'état interne du système

Mise en pratique


Appliquons tout cela à notre méthode précédente et à sa suite de tests. Pour cela nous allons utiliser Stryker, une librairie de mutation testing disponible en JavaScript, Scala et C#. Pour les langages basés sur la JVM, pitest est une implémentation équivalente.

De part le principe même du mutation-testing, aucun effort autre que la configuration de la librairie n'est nécessaire.

Exécution des tests de mutation

Après exécution, nous avons un premier niveau de rapport dans le retour console nous apprenant entre autre que :

  • 6 mutants ont été générés.
  • Sur ces 6 mutants, 2 ont survécu à notre suite de tests.
  • Notre suite de tests a un score de 66% (1/3 des mutations n'ont pas été détectés). ​ Nous voyons très vite que les 2 mutations non détectées concernent effectivement le "trou" que nous avions prédit. ​

1ère mutation ayant survécu :

1ère mutation ayant survécue

La mutation a modifié notre comparaison afin de renvoyer toujours true. Notre suite de test ne vérifiant que le cas où l'on renvoie effectivement true, la mutation a survécu, c'est-à-dire que notre test n'a pas échoué.

2ème mutation ayant survécu :

2ème mutation ayant survécue

De la même manière, ne testant pas le cas de l'égalité, la mutation associée n'a pas été détectée.

L'analyse des résultats nous amène donc à renforcer notre suite de test comme suit:

const { estMajeur } = require('../src/majorite')

describe("Majorité", () => {
    test("Devrait dire lorsqu'un age est majeur", () => {
      expect(estMajeur(35)).toBe(true)
    });
    test("Devrait être majeur à 18 ans", () => {
      expect(estMajeur(18)).toBe(true)
    });  
    test("Devrait dire lorsqu'un age est mineur", () => {
      expect(estMajeur(12)).toBe(false)
    });
});


Notre couverture de code est toujours de 100%, en revanche notre score de mutation est maintenant lui aussi de 100%.

Exécution des tests de mutation sur la suite de test complétée

Conclusion


Au delà du gimmick "testez vos tests", il est important de sensibiliser l'ensemble des acteurs à l'évaluation de la pertinence des tests unitaires. Dans cette optique, le mutation testing est une pratique à l'outillage facile à mettre en place et fournissant un feedback de valeur très rapidement.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player