03/06/2013

Mutation Testing en PHP : indicateurs de qualité des tests unitaires

Aujourd'hui, dans l'écosystème PHP, on ne se pose enfin plus la question de savoir ce qu'est un test unitaire. Les tests unitaires sont devenus une pratique courante, et il existe des frameworks de tests matures, comme PHPUnit ou atoum.

L'engouement pour la qualité logicielle a poussé la communauté à progresser et à proposer de nouvelles pratiques, de nouveaux outils... Cependant, il reste encore exceptionnel de contrôler la qualité des tests unitaires produits.

Attention, par contrôle de qualité des tests, je ne parle pas d'indicateurs de couverture de code. Non, car une couverture de code à 100 % est contre-productive, impossible et surtout totalement fausse. Et voici pourquoi.

Couverture de nœuds et couverture de chemins

Les outils de couverture actuelle ont un défaut majeur, inhérent à leur fonctionnement : ils indiquent uniquement si des portions de code ont été exécutées lors des tests unitaires.

Or, prenons un exemple simple :

<?php
class Foo {
    public function bar($x, $y) {
        if($x == 1) {
            echo 'A';
        } else {
            echo 'B';
        }
        
        if($y == 1) {
            echo 'C';
        } else {
            echo 'D';
        }
    }
}

et le test unitaire suivant :

<?php
<p>require_once __DIR__.'/Foo.php';</p>
class FooTest extends PHPUnit_Framework_TestCase
{
    public function testFoo1()
    {
        $foo = new Foo;
        $foo->bar(1, 2);
        $foo->bar(2, 1);
    }
}

Le premier appel de bar() affiche 'AD', le second affiche 'BC'. Je suis donc passé partout dans mon code source, la couverture de code de ma suite de test est de 100 % :

Couverture de code PHP à 100%

Super ! 100 % ! J'ai couvert tout mon code ! En plus c'est vert, c'est donc que tout va bien.

Et bien non… ou plutôt, je suis passé à chaque nœud de mon code source, mais je n'ai pas couvert tous les chemins possibles de code. Car avec ces deux if(), le code peut faire :

  • A, C
  • A, D
  • B, C
  • B, D

Quatre chemins possibles donc, alors que la couverture de code est de 100 % en à peine deux tests. La couverture de code est donc un indicateur assez peu fiable, puisqu'elle nous trompe allègrement : dans mon exemple, très simple, je n'ai en réalité couvert que 50 % des chemins possibles ; et encore, uniquement pour cette portion de code. Et le code est très simple : imaginez un switch() avec des if() imbriqués, vous verrez qu'en réalité la différence entre la réalité et la couverture de code indiquée est exponentielle.

Pire : les test unitaires qui ne testent rien

Attendez, j'ai donc ici une couverture de code de 100 %, mais, en réalité, mes tests unitaires ne font strictement rien. Il n'y a même pas d'assertion !

Il arrive en effet très fréquemment que les tests unitaires ne servent à rien. Oui, même dans la vraie vie, même sur de gros projets. C'est encore plus vrai lorsqu'on commence à " sur-mocker " tout et n'importe quoi : un mock par-ci, un mock par-là... Il m'arrive de voir des tests unitaires où les assertions portent sur des mocks ; autant ne pas écrire de test unitaire.

Quel indicateur de qualité alors pour les tests unitaires?

Vous l'avez compris, il est difficile d'obtenir des indicateurs fiables de qualité pour des tests unitaires. Et encore, on ne cherche que des indicateurs...

C'est ici qu'intervient le Mutation Testing. Dès les années 70 (oui, c'est vieux), s'est posée la question de savoir comment résoudre cette question de la qualité de tests.

L'idée du Mutation Testing consiste à introduire des bugs dans le code source, puis à vérifier si les tests unitaires ont bien détecté ces bugs. Autrement dit, nous introduisons des mutations dans le code source (on parle de " mutants "), les tests unitaires sont sensés les détecter (on dit alors que le mutant en question est " tué ").

Si tous les bugs ont bien été détectés, c'est sans doute que les tests unitaires sont fiables. Si aucun bug n'est détecté, les tests unitaires ne servent à rien. Dire que tous les mutants ont été tués est donc un indicateur de la bonne qualité de tests unitaires.

Les avantages du Mutation Testing sont nombreux :

  • c'est efficace
  • c'est très simple et ne requiert pas de développement spécifique en plus des tests unitaires
  • c'est assez précis.

Par contre, le Mutation Testing a un certain nombre d'inconvénients :

  • C'est très très long à s'exécuter (le volume de mutations possibles est exponentielle)
  • Il arrive qu'il y ait des mutants impossible à tuer

Maintenant que les bases sont posées, parlons outils. Il n'existait en PHP, à ma connaissance, qu'un seul outil pour le Mutation Testing : Mutagenesis. Malheureusement, cet outil, pourtant très intéressant, a, malgré ses avantages, trois inconvénients :

  • il est sorti trop tôt, à une époque où tester son code restait encore une pratique anecdotique en PHP
  • le dernier commit date de plus d'un an
  • il nécessite d'installer une extension PHP assez lourde : RunKit

Partant de ce constat, j'ai réfléchi à une solution pour proposer un outil plus moderne et moins lourd. J'ai donc démarré le développement de MutaTesting. A titre personnel, je dois avouer que ça m'intéressait beaucoup de voir comment résoudre les difficultés inhérentes à cet outil sans devoir passer par l'installation de RunKit ou de xDebug.

MutaTesting, un nouvel outil pour la qualité PHP

MutaTesting, c'est quoi ? C'est un outil PHP qui crée des mutants à partir votre code source puis lance vos tests unitaires pour voir s'il est possible de tuer ces mutants.

Mon idée première a été de faire un outil très simple : pas besoin d'extension PHP, pas besoin de configuration compliquée ; il suffit, en ligne de commande, d'indiquer trois choses :

  • le framework de test utilisé
  • le chemin du binaire à exécuter pour lancer les tests
  • le dossier des tests unitaires

Par exemple, pour une suite de tests PHPUnit :

./bin/mutatesting phpunit phpunit.phar myTestFolder

ou pour atoum :

./bin/mutatesting atoum mageekguy.atoum.phar myTestFolder

C'est tout. A partir de là, MutaTesting va procéder à un certain nombre de processus :

  1. les tests vont être lancés une première fois
  2. chaque suite de test va être isolée, puis relancée pour déterminer quelles sources PHP elle permet de tester
  3. le code source est converti en tokens, puis chaque token transformable est transformé en mutant
  4. chaque suite de test va être relancée sur chaque mutation de code

Bien entendu, votre code source n'est jamais modifié. En réalité, l'outil joue avec un StreamWrapper spécifique pour le flux de fichier standard (file://) pour substituer la mutation à votre code originel.

Voici quelques exemples de bugs qui peuvent être introduits :

  • remplacer un test d'égalité (" == ") par un test de non-égalite (" != ")
  • remplacer " true " par " false "
  • supprimer un bloc " else "
  • ...

Voici le résultat de la mutation pour les tests de MutaTesting même :

Résulat global de la mutation

L'analyse du code source a donc permis de créer 46 mutants, donc 26 ont survécu. Le score des tests est donc de 43 %.

Les tests unitaires ont donc des anomalies. Relançons le même outil pour obtenir un compte rendu au format HTML, plus complet (option –format=html). Voici un aperçu de ce qui est obtenu :

Rapport détaillé HTML de la mutation

Cette fois-ci on a plus de détails : les mutants sont détaillés et regroupés par fichier de source.

Ce qui est intéressant, c'est que les sources qui ressortent comme les moins bien testées sont justement celles que je n'ai pas développées par TDD. L'analyse de ce rapport va donc me permettre de me focaliser sur les tests unitaires qui concernent les sources sur lesquelles le plus de mutations a survécu.

Conclusion

Le Mutation Testing est donc finalement assez simple : on introduit des bugs dans un code source, puis on vérifie qu'ils sont bien détectés par les tests unitaires.

Ce qui est surtout intéressant c'est que cette pratique ne nécessite aucun développement en plus, mais simplement l'utilisation d'un outil, automatisé

Concernant MutaTesting même, que j'ai développé et qui est disponible sur Github, je pense que c'est un outil que j'espère simple à utiliser et à étendre. Bien que fonctionnel, il peut largement être amélioré. Les Pull Requests sont les bienvenues :-) .

De la même façons, vos retours sur les bienvenus : sur vos pratiques, vos attentes en terme d'outils...si l'envie vous prend de l'essayer, voire d'y contribuer.

PS: j'ai eu pas mal de remarques sur la lisibilité du thème de ce blog. J'ai changé de thème ; c'est mieux ? :p

blog comments powered by Disqus