Évaluez la qualité de vos tests avec les tests de mutation

Très souvent, lorsque l’on souhaite mettre en place des indicateurs sur les tests d’un projet, la première mesure que l’on va mettre en place est celle de la couverture de code. Et pour beaucoup, il s’agit de la métrique principale permettant d’avoir une idée de qualité des tests d’un projet. Cette dernière sous-entend que plus la couverture est élevée et plus les tests assurent la sécurité du fonctionnement de notre code. Il s’agit pourtant d’un indicateur qui peut être trompeur.

Il s’agit d’un sujet que j’ai déjà abordé dans ce blog, expliquant que ce n’est pas parce qu’un test exécute une ligne de code que la vérification du comportement associé est efficace et pertinente. C’est dans ce cadre-là, qu’il est possible de mettre en place des tests de mutation (du mutation testing en anglais).

Les tests de mutation introduisent volontairement des défauts dans le code source d’un projet dans le but de vérifier si les tests permettent de détecter les changements de comportement introduit. Ces “défauts” sont appelés des “mutants” (d’où le nom de tests de mutation) et correspondent à des modifications mineures du code tentant de simuler des erreurs de programmations courantes. À la suite de l’exécution de ces tests, un score de mutation est retourné. Ce dernier représente le pourcentage de mutants tués. Plus le score est élevé, plus les tests sont efficaces pour détecter les erreurs.

Parmi les mutations les plus courantes, on retrouve:

  • le remplacement des opérateurs mathématiques: un + qui devient un -,
  • la modification des opérateurs de comparaison: une condition d’égalité === qui devient une négation !==,
  • la suppression de lignes de code,
  • la modification des valeurs booléennes: un true qui devient false,
  • la modification de valeurs de retour dans les fonctions.

Les tests de mutations permettant alors d’évalue la capacité des tests à détecter des erreurs et pas simplement à exécuter duc ode. Ils mettent en évidence les parties du code où les tests sont insuffisants ou inefficaces. Cela permet ainsi d’ajouter des tests aux endroits les plus pertinents. Au final, ils permettent d’améliorer la confiance envers la suite de tests.

En PHP, l’outil le plus connu pour cela est certainement Infection. Ce dernier supporte PHPUnit, PhpSpec et Codeception, nécessite PHP 7.4 au minimum avec au moins une des extensions suivantes: Xdebug, phpdbg ou pcov d’installé. Son installation est extrêmement simple, puisqu’il suffira de faire un composer require --dev infection/infection. Une configuration de base sera automatiquement créée au premier lancement d’Infection permettant un démarrage extrêmement rapide.

Prenons un exemple simple pour illustrer le principe. Une classe Calculator qui permet d’effectuer des opérations de calcul:

class Calculator
{
    public function add(int $x, int $y): int
    {
        return $x + $y;
    }

    public function divide(int $x, int $y): float
    {
        if ($y === 0) {
            throw new \InvalidArgumentException('Division by zero is not allowed.');
        }

        return $x / $y;
    }
}

Cette classe est associée aux tests suivants:

use PHPUnit\Framework\TestCase;

final class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        $addition = new Calculator();

        $result = $addition->add(2, 3);

        $this->assertEquals(5, $result);
    }

    public function testDivide(): void
    {
        $addition = new Calculator();

        $result = $addition->divide(6, 2);

        $this->assertEquals(3, $result);
    }
}

En lançant les tests de mutation (via la commande vendor/bin/infection), nous obtenons le rapport suivant:

Metrics:
   Mutation Score Indicator (MSI): 71%
   Mutation Code Coverage: 85%
   Covered Code MSI: 83%

Le Mutation Score Indicator représente le pourcentage de mutants tués par rapport au nombre total de mutants générés. Un mutant tué correspond à au moins un test qui échoue après l’application d’un mutant. Elle constitue la métrique principale des tests de mutation. Elle qui permet de mesurer l’efficacité des tests à détecter des erreurs. Le score doit alors être le plus élevé possible.

Le Mutation Code Coverage correspond au pourcentage de code parcouru par les tests de mutations. Cette métrique est similaire à la couverture de code “classique”, mais du point de vue des mutations.

La dernière métrique, le Covered code MSI est un calcul effectué uniquement sur le code couvert par les tests. Elle permet notamment d’isoler l’efficacité des tests existants. Cet indicateur répond à la question: “Parmi le code que mes tests exécutent, quelle est leur efficacité à détecter des erreurs”. Cet indicateur permet de détecter si vos tests sont suffisamment présents (indicateur élevé) ou si ces derniers ne permettent pas de détecter les problèmes de manière efficace (indicateur bas).

Pour améliorer le score des tests de mutation et par rebond la qualité de vos tests en général, il faudra tester tous les chemins d’exécution de votre code (incluant les cas limites et les exceptions). De plus, il ne faut pas se contenter de tester les cas nominaux, il est important de tester les cas d’erreurs.

Par exemple dans les tests que nous avons écrit précédement, nous n’avons pas testé le cas d’erreur de la division par zéro. Modifions le code précédent, pour ajouter ce dernier:

use PHPUnit\Framework\TestCase;

final class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        $addition = new Calculator();

        $result = $addition->add(2, 3);

        $this->assertEquals(5, $result);
    }

    public function testDivide(): void
    {
        $addition = new Calculator();

        $result = $addition->divide(6, 2);

        $this->assertEquals(3, $result);
    }

    public function testDivideByZero(): void
    {
        $addition = new Calculator();

        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Division by zero is not allowed.');

        $addition->divide(6, 0);
    }
}

Nous obtenons alors le résultat ci-dessous:

Metrics:
   Mutation Score Indicator (MSI): 100%
   Mutation Code Coverage: 100%
   Covered Code MSI: 100%

Si mettre en place un calcul de la couverture de code de vos tests est un bon premier point, si vous souhaitez aller plus loin et avoir une métrique pertinente pour avoir une idée de la qualité de vos tests, les tests de mutation sont une très bonne approche. Vous aurez alors les outils pour identifier et combler les “trous” de vos tests. Et comme toute solution, il sera nécessaire de prêter attention à quelques points.

Tout d’abord, il est important de noter que les tests de mutation sont des tests qui sont coûteux en termes de performance. La création et la mise en place de mutants sont consommateur de ressources de calcul et de temps d’exécution. La génération de mutant peut ne pas être parfaite et peut ne pas changer le comportement observable du code. Dans ce dernier cas de figure, les mutants ne peuvent être tués, faussant ainsi le calcul final. On parle alors de “mutants équivalents”. Ces derniers entrainent alors de faux positifs, car ils ne changent pas réellement le comportement attendu.

Jérémy DECOOL

Jérémy DECOOL

Développeur depuis plus d'une décennie, je partage mes réflexions sur les bonnes pratiques de développement et d'architecture logicielle.