Introduction aux tests de mutation

“Mutation Testing” (en français : tests de mutation), j’ai récemment découvert ce terme décrivant un processus capable de détecter les lacunes des tests unitaires, en allant au-delà de la couverture du code. Aujourd’hui, je vous présente cette démarche consistant à réaliser ces tests en malmenant le code.

Tests unitaires

Considérant qu’il n’est plus à démontrer l’utilité des tests unitaires, ce sujet devient intéressant si vous développez un projet testé, quelque soit l’importance de la couverture de code.
Les tests unitaires permettent la mise en évidence d’éventuelles régressions provoquées par une modification du code. En théorie, si les tests valident le programme, c’est que tout fonctionne correctement dans l’application. Comme première mesure de confiance et souvent la seule, nous utilisons la couverture de code. Plus cette métrique s’approche des 100%, plus elle nous rassure qu’aucune régression ne passera entre les mailles du filet. Malheureusement, cette affirmation reste théorique.
Les tests, bien qu’indispensables à la validation d’une application qualitative, il est difficile d’en démontrer voire d’en apprécier leur pertinence.

Couverture de code et couverture de cas

Une couverture de code à 100% ne signifie pas un code validé à 100% mais seulement 100% de ce code exécuté lors du passage des tests, rien de plus.
La couverture de code (ligne, statement, branch, etc…) mesure uniquement quel code a été exécuté par les tests, sans garantie de détection des défauts. Elle est seulement capable d’identifier le code qui n’est toujours pas testé.
Le test sans assertion en est l’exemple évident car, bien qu’exécuté, le code n’est pas réellement testé. Heureusement ce cas de figure reste rare, le plus courant est de rencontrer un code partiellement testé par la suite de tests. Une suite qui ne teste que partiellement du code pouvant toujours exécuter toutes ses branches.
Dans certains cas, la couverture de code n’est pas un indicateur de protection. En voici un exemple simple :

function isAdult(user) {
    return user.age >= 18;
}

Supposons que l’on souhaite vérifier l’âge d’un utilisateur. Nous allons écrire le code suivant pour s’assurer qu’il soit majeur.
Pour tester ce code nous pouvons essayer avec 12 et 38 ans en entrée. Cette action suffirait à couvrir ce code à 100%.
Le résultat serait identique si nous omettions de considérer 18 ans comme la majorité avec cette faute de frappe dans notre code :

function isAdult(user) {
    return user.age > 18;
}

…ou si nous testions la seule valeur 12 ans, voire pire si nous oublions l’assertion dans notre test.
Le test de mutation sera réellement capable de détecter si chaque affirmation est testée de manière significative. C’est la mesure étalon de tous les autres types de couverture.

Autres soucis dans le code

Prenons ensuite comme hypothèse de ne pas vouloir de code inutile dans notre application. En effet, chaque partie non testée sera une source de bug potentiel voire de complexité supplémentaire si elle n’est pas indispensable.
Voici pourquoi le test de mutation est un excellent moyen d’éprouver la pertinence d’un tel code :
if (someVariable !== null && someVariable.hasValue()) {}
Avons nous besoin de vérifier la valeur “null” ? La condition a-t-elle été ajoutée par habitude ? Cela pourrait signifier que nous sommes incertains de la variable “someVariable” et justifierait plus d’analyses. Nous ne pouvons approfondir sans en avoir pris conscience. Les tests de mutation nous aident également en cela.

Tests de Mutation : De quoi s’agit-il ?

Pour détecter les failles dans nos tests unitaires, il existe une solution : les tests de mutation.

Cette technique permet d’accorder plus de confiance à nos tests. Le test de mutation est un concept assez simple. Son principe est de maltraiter le code source en l’altérant pour vérifier que les tests associés échouent en conséquence. Les failles (ou mutations) sont automatiquement ensemencées dans notre code, puis les tests sont exécutés. Si les tests échouent, alors la mutation est tuée. Si les tests passent, alors la mutation a survécu. Dans ce cas, cela signifie que les tests ne correspondent pas à la complexité du code et laissent non-testés un ou plusieurs de ses aspects. Ensuite, la qualité de nos tests peut être mesurée à partir du pourcentage de mutations tuées.
En d’autres termes, nous exécutons les tests unitaires sur des versions du code modifiées automatiquement. Lorsque le code de l’application change, il doit produire des résultats différents et provoquer l’échec des tests unitaires. Si un test unitaire n’échoue pas dans cette situation, cela peut indiquer un manquement dans la suite de tests.
Voici les étapes pour y parvenir :

  • Lancer la suite de tests habituelle pour vérifier que tous les tests passent au vert.
  • Modifier certaines parties du code testé avant de relancer la suite de tests une nouvelle fois.
  • S’assurer que les tests ont échoué comme attendu après la modification (mutation) du code testé.

Reprendre les étapes 2 et 3 tant qu’il reste des mutations possibles.
Prenons un exemple concret : pensez à un mutant comme une classe supplémentaire avec une seule modification par rapport au code d’origine. Cela peut être le changement d’un opérateur logique dans une clause if comme indiqué ci-dessous :
if( a || b ) {…} => if( a && b ) {…}
La détection et le rejet d’une telle modification par les tests existants est désigné comme le fait de tuer un mutant. Avec une suite de tests parfaite en place, aucun mutant de classe ne survivrait. Mais la création de tous les mutants possibles est gourmande en ressources, c’est pourquoi il n’est pas possible de réaliser cette approche manuellement dans des scénarios réels.
Heureusement, il existe des outils disponibles afin de créer des mutants à la volée et exécuter automatiquement tous les tests pour chacun d’entre-eux. La création de transformation est basée sur un ensemble d’opérateurs de mutation appelés à révéler des erreurs de programmation typiques. L’opérateur de mutation employé pour modifier le code ci-dessus est appelé opérateur de condition.

En pratique

Cette technique se compose donc de deux parties : la génération de mutants, puis l’élimination de ceux-ci.
La génération de mutants est l’étape qui consiste à générer des classes mutantes à partir de classes sources. Pour débuter, il faut le code métier sur lequel nous souhaitons évaluer la pertinence de nos tests. Nous prenons ensuite un « pool » de mutations possibles, une mutation étant une modification du code source, comme par exemple, l’action de remplacer un opérateur par un autre.
Voici quelques exemples :

  • + devient –
  • * devient /
  • >= devient ==
  • true devient false.
  • la suppression d’une instruction
  • etc.

On peut modifier une expression arithmétique e en |e| (ABS), modifier un opérateur relationnel arithmétique par un autre (ROR), modifier un opérateur arithmétique par un autre (AOR), modifier un opérateur booléen par un autre (COR), modifier une expression bool/arithmétique en ajoutant − ou ¬ (UOI), modifier un nom de variable par un autre, modifier un nom de variable par une constante du même type, modifier une constante par une autre constante du même type…
La génération à proprement parler consiste à parcourir toutes les instructions du code et pour chacune, de déterminer si des mutations sont applicables. Si oui, chaque mutation donnera naissance à un nouveau mutant.
Pour l’instruction suivante :

if (a > 8) { x = y+1 }

On peut considérer les mutants suivants :

 if (a < 8) { x = y+1 }
 if (a ≥ 8) { x = y+1 }
 if (a > 8) { x = y-1 }
 if (a > 8) { x = y }

Ce processus peut être rapidement gourmand en ressources. Lorsque le code que l’on souhaite muter contient un grand nombre d’instructions et que le « pool » de mutations possibles est conséquent, alors le nombre de mutants générés augmente très rapidement.
Une fois le processus de génération de mutants terminé, les mutants sont stockés en attendant la prochaine étape : l’élimination !
Pour la deuxième partie du processus, on a généré un grand nombre de mutants qu’on ne veut pas laisser passer au travers des tests ; le but sera d’en éliminer le plus possible. Pour ce faire, notre arme sera l’amélioration des tests unitaires.

Bilan

Pour un mutant donné, il y a deux résultats possibles, soit les tests sont toujours au vert, soit au moins l’un d’eux est passé au rouge.

Habituellement, nous souhaitons que les tests soient au vert. Mais dans ce contexte, nous cherchons à obtenir du rouge. En effet, comme nous l’avions vu plus tôt, chaque mutant est censé faire échouer au moins l’un des tests unitaires. Si au minimum un des tests est en échec, cela prouve qu’ils sont capables de détecter les modifications du code et donc de prévenir d’éventuels bugs. En revanche, si tous les tests demeurent au vert, le mutant survit, il est donc resté invisible aux yeux de nos tests.
Un mutant qui survit est donc le signe d’un test manquant !

Limitations

L’analyse complète de notre code peut s’avérer fastidieuse. Comme nous l’avons vu, le nombre de mutants peut augmenter très rapidement.
Sur une première phase, nous pouvons par exemple générer 6000 mutants. Lors de la deuxième phase de test, plus de 98% d’entre-eux vont être éliminés, le pourcentage variant en fonction de la qualité préalable de vos tests. Il nous reste tout de même 150 à 200 mutants.
Une analyse manuelle de chacun d’eux s’avère chronophage. De plus, nos tests unitaires ne sont pas seuls responsables de leur survie. Il peut apparaître un « mutant équivalent » : un mutant qui modifie la syntaxe du code source, sans en changer sa sémantique. Ce type de mutant empêche un test unitaire de le détecter.

while(...) {
    index++;
    if (index == 10)
    break;
}

Par exemple, une mutation de « == » vers « <= » produira un mutant équivalent. Cet exemple aura la même condition de sortie de la boucle.
L’analyse préalable de couverture de code, la création de mutants à la volée et tous les tests nécessaires consomment beaucoup de temps. Par exemple, un code avec 350 tests augmente le temps d’exécution par quatre par rapport à un run habituel.
Compte tenu de ces chiffres et pour des raisons pratiques, les tests de mutation ne peuvent pas être exécutés aussi fréquemment que les tests unitaires. Par conséquent, il est important de trouver un flux de travail approprié offrant le meilleur compromis en terme d’efficience. Pour les grands systèmes logiciels, cela pourrait signifier que les tests de mutation seraient limités à des runs nocturnes.
Avant de les mettre en oeuvre, il faut être dans une démarche de qualité avancée. Les tests doivent se placer au centre du développement, pour éviter des résultats trop volumineux à analyser. Cependant, si la couverture de code a atteint ses limites cela peut être une bonne approche à expérimenter. Malheureusement, les outils actuels ne semblent pas assez industrialisés.

Les tests de mutations et le javascript

Les tests de mutation sont bien plus connus et utilisés dans le monde du Java ou encore en PHP. Cependant, depuis 2016 il existe un moyen de réaliser des tests de mutations en JavaScript grâce à Stryker Mutator. Il existe aussi Grunt-mutation-testing dont la majorité du code source est en train de migrer vers Stryker.
Voici le lien vers le Github : http://stryker-mutator.github.io

Conclusion

Cet article était une introduction rapide aux tests de mutation. Nous avons abordé les mutants de test, apprécié la relation directe entre le taux de mutants et la qualité d’une suite de tests existante et observé la corrélation avec la couverture de code.
La couverture de code n’étant pas une métrique très fiable, les tests de mutation sont un moyen rapide et simple de mesurer la fiabilité des tests unitaires. Nous favoriserons les tests de mutation là où il y a un véritable enjeu : le code métier.
Au final, le test de mutation semble être un complément intéressant à un ensemble d’outils d’assurance qualité, basé sur des tests automatisés. Cette pratique est assez récente en JavaScript et encore méconnue. Il sera intéressant de lire les avis et retours d’expérience d’utilisateurs avancés.
Aurélie Ambal, (@Souvir) JS Craftswoman @JS-Republic
[actionbox color=”default” title=”” description=”JS-REPUBLIC Training est référencé par Datadock.
Retrouvez toutes nos formations sur notre site training.ux-republic.com :

  • UX-Design
  • Agile
  • JavaScript

” btn_label=”Nos formations” btn_link=”http://training.ux-republic.com” btn_color=”primary” btn_size=”big” btn_icon=”star” btn_external=”1″]