Les tests d'Ă©criture devraient inspirer confiance dans le bon fonctionnement du code. Souvent, nous opĂ©rons sur le degrĂ© de couverture du code, et lorsque nous atteignons 100%, nous pouvons dire que la solution est correcte. Ătes-vous sĂ»r de cela? Peut-ĂȘtre existe-t-il un outil qui donnera des commentaires plus prĂ©cis?
Test de mutation
Ce terme dĂ©crit une situation oĂč nous modifions de petits morceaux de code et voyons comment cela affecte les tests. Si, aprĂšs les modifications, les tests sont effectuĂ©s correctement, cela indique que pour ces morceaux de code, les tests ne sont pas suffisants. Bien sĂ»r, tout dĂ©pend de ce que nous changeons exactement, car nous n'avons pas besoin de tester tous les plus petits changements, par exemple, les retraits ou les noms de variables, car aprĂšs eux, les tests doivent Ă©galement se terminer correctement. Par consĂ©quent, dans les tests de mutation, nous utilisons les soi-disant mutateurs (mĂ©thodes de modification), qui remplacent un morceau de code par un autre, mais d'une maniĂšre qui a du sens. Nous en parlerons plus en dĂ©tail ci-dessous. Parfois, nous effectuons nous-mĂȘmes de tels tests, vĂ©rifiant si les tests se cassent si nous changeons quelque chose dans le code. Si nous avons refactorisĂ© «la moitiĂ© du systĂšme» et que les tests sont toujours verts, alors nous pouvons immĂ©diatement dire qu'ils sont mauvais. Et si quelqu'un a fait ça et que les tests ont Ă©tĂ© bons, alors fĂ©licitations!
Cadre infectieux
Aujourd'hui en PHP, le cadre de test de mutation le plus populaire est
Infection . Il prend en charge PHPUnit et PHPSpec, et pour fonctionner avec cela, il faut PHP 7.1+ et Xdebug ou phpdbg.
Premier lancement et configuration
Au premier démarrage, nous voyons le configurateur interactif du framework, qui crée un fichier spécial avec les paramÚtres - infection.json.dist. Cela ressemble à ceci:
{ "timeout": 10, "source": { "directories": [ "src" }, "logs": { "text": "infection.log", "perMutator": "per-mutator.md" }, "mutators": { "@default": true }
Timeout
- une option dont la valeur doit ĂȘtre Ă©gale Ă la durĂ©e maximale d'un test. En
source
nous spécifions les répertoires à partir desquels nous muterons le code, vous pouvez définir des exceptions. Dans les
logs
il existe une option de
text
, que nous définissons pour collecter des statistiques sur les tests erronés uniquement, ce qui est le plus intéressant pour nous. L'option
perMutator
vous permet de sauvegarder les mutateurs utilisés. En savoir plus à ce sujet dans la documentation.
Exemple
final class Calculator { public function add(int $a, int $b): int { return $a + $b; } }
Disons que nous avons la classe ci-dessus. Ăcrivons un test en PHPUnit:
final class CalculatorTest extends TestCase { private $calculator; public function setUp(): void { $this->calculator = new Calculator(); } public function testAdd(int $a, int $b, int $expected): void { $this->assertEquals($expected, $this->calculator->add($a, $b)); } public function additionProvider(): array { return [ [0, 0, 0], [6, 4, 10], [-1, -2, -3], [-2, 2, 0] ]; } }
Bien sĂ»r, ce test doit ĂȘtre Ă©crit avant d'implĂ©menter la mĂ©thode
add()
. Lors de l'exécution de
./vendor/bin/phpunit
nous obtenons:
PHPUnit 8.2.2 by Sebastian Bergmann and contributors. .... 4 / 4 (100%) Time: 39 ms, Memory: 4.00 MB OK (4 tests, 4 assertions)
Exécutez maintenant
./vendor/bin/infection
:
You are running Infection with Xdebug enabled. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
Selon Infection, nos tests sont précis. Dans le fichier
per-mutator.md, nous pouvons voir quelles mutations ont été utilisées:
Mutator Plus est un simple changement de signe de plus en moins, ce qui devrait casser les tests. Et le mutateur
PublicVisibility
change le modificateur d'accÚs de cette méthode, qui devrait également casser les tests, et dans ce cas, cela fonctionne.
Ajoutons maintenant une méthode plus compliquée.
public function findGreaterThan(array $numbers, int $threshold): array { return \array_values(\array_filter($numbers, static function (int $number) use ($threshold) { return $number > $threshold; })); } public function testFindGreaterThan(array $numbers, int $threshold, array $expected): void { $this->assertEquals($expected, $this->calculator->findGreaterThan($numbers, $threshold)); } public function findGreaterThanProvider(): array { return [ [[1, 2, 3], -1, [1, 2, 3]], [[-2, -3, -4], 0, []] ]; }
AprÚs l'exécution, nous verrons le résultat suivant:
You are running Infection with Xdebug enabled. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
Nos tests ne vont pas bien. Tout d'abord, vérifiez le fichier infection.log:
Escaped mutants: ================ 1) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:19 [M] UnwrapArrayValues --- Original +++ New @@ @@ */ public function findGreaterThan(array $numbers, int $threshold) : array { - return \array_values(\array_filter($numbers, static function (int $number) use($threshold) { + return \array_filter($numbers, static function (int $number) use($threshold) { return $number > $threshold; - })); + }); } 2) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:20 [M] GreaterThan --- Original +++ New @@ @@ public function findGreaterThan(array $numbers, int $threshold) : array { return \array_values(\array_filter($numbers, static function (int $number) use($threshold) { - return $number > $threshold; + return $number >= $threshold; })); } Timed Out mutants: ================== Not Covered mutants: ====================
Le premier problĂšme non
array_values
est l'utilisation de la fonction
array_values
. Il est utilisé pour réinitialiser les clés, car
array_filter
renvoie les valeurs avec les clĂ©s du tableau prĂ©cĂ©dent. De plus, dans nos tests, il n'y a pas de cas oĂč il est nĂ©cessaire d'utiliser
array_values
, car sinon un tableau avec les mĂȘmes valeurs mais des clĂ©s diffĂ©rentes est retournĂ©.
Le deuxiÚme problÚme est lié aux cas limites. En comparaison, nous avons utilisé le signe
>
, mais nous ne testons aucun cas de bordure, donc le remplacement par
>=
ne rompt pas les tests. Vous devez ajouter un seul test:
public function findGreaterThanProvider(): array { return [ [[1, 2, 3], -1, [1, 2, 3]], [[-2, -3, -4], 0, []], [[4, 5, 6], 4, [5, 6]] ]; }
Et maintenant, l'infection est satisfaite de tout:
You are running Infection with Xdebug enabled. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
Ajoutez une méthode de
subtract
Ă la classe
Calculator
, mais sans test séparé dans PHPUnit:
public function subtract(int $a, int $b): int { return $a - $b; }
Et aprÚs avoir exécuté Infection, nous voyons:
You are running Infection with Xdebug enabled. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
Cette fois, l'outil a renvoyé deux mutations non découvertes.
Escaped mutants: ================ Timed Out mutants: ================== Not Covered mutants: ==================== 1) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:24 [M] PublicVisibility --- Original +++ New @@ @@ return $number > $threshold; })); } - public function subtract(int $a, int $b) : int + protected function subtract(int $a, int $b) : int { return $a - $b; } 2) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:26 [M] Minus --- Original +++ New @@ @@ public function subtract(int $a, int $b) : int { - return $a - $b; + return $a + $b; }
Mesures
AprÚs chaque exécution, l'outil renvoie trois métriques:
Metrics: Mutation Score Indicator (MSI): 47% Mutation Code Coverage: 67% Covered Code MSI: 70%
Mutation Score Indicator
- le pourcentage de mutations détectées par les tests.
La métrique est calculée comme suit:
TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount; MSI = (TotalDefeatedMutants / TotalMutantsCount) * 100;
Mutation Code Coverage
du code de mutation - La proportion de code couverte par des mutations.
La métrique est calculée comme suit:
TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount; CoveredRate = (TotalCoveredByTestsMutants / TotalMutantsCount) * 100;
Covered Code Mutation Score Indicator
- détermine l'efficacité des tests uniquement pour le code couvert par les tests.
La métrique est calculée comme suit:
TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount; TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount; CoveredCodeMSI = (TotalDefeatedMutants / TotalCoveredByTestsMutants) * 100;
Utilisation dans des projets plus complexes
Dans l'exemple ci-dessus, il n'y a qu'une seule classe, nous avons donc exécuté Infection sans paramÚtres. Mais dans le travail quotidien sur des projets ordinaires, il sera utile d'utiliser le paramÚtre
âfilter
, qui vous permet de spécifier l'ensemble de fichiers auquel nous voulons appliquer des mutations.
./vendor/bin/infection --filter=Calculator.php
Faux positifs
Certaines mutations n'affectent pas les performances du code et Infection renvoie un MSI inférieur à 100%. Mais nous ne pouvons pas toujours faire quelque chose avec cela, nous devons donc composer avec de telles situations. Quelque chose de similaire est montré dans cet exemple:
public function calcNumber(int $a): int { return $a / $this->getRatio(); } private function getRatio(): int { return 1; }
Bien sûr, ici, la méthode
getRatio
pas de sens, dans un projet normal, il y aurait probablement une sorte de calcul Ă la place. Mais le rĂ©sultat pourrait ĂȘtre
1
. L'infection revient:
Escaped mutants: ================ 1) /home/sarven/projects/infection-playground/infection-playground/src/Calculator.php:26 [M] Division --- Original +++ New @@ @@ public function calcNumber(int $a) : int { - return $a / $this->getRatio(); + return $a * $this->getRatio(); } private function getRatio() : int
Comme nous le savons, multiplier et diviser par 1 renvoie le mĂȘme rĂ©sultat, Ă©gal au nombre d'origine. Cette mutation ne doit donc pas casser les tests, et malgrĂ© la dissection d'Infection concernant la prĂ©cision de nos tests, tout est en ordre.
Optimisation pour les grands projets
Dans les cas avec de grands projets, l'infection peut prendre beaucoup de temps. Vous pouvez optimiser l'exécution pendant CI si vous ne traitez que des fichiers modifiés. Pour plus d'informations, consultez la documentation:
https://infection.imtqy.com/guide/how-to.htmlDe plus, vous pouvez exĂ©cuter des tests sur le code modifiĂ© en parallĂšle. Cependant, cela n'est possible que si tous les tests sont indĂ©pendants. Ă savoir, ce devrait ĂȘtre de bons tests. Pour activer cette option, utilisez l'
âthreads
:
./vendor/bin/infection --threads=4
Comment ça marche?
Le cadre d'infection utilise AST (Abstract Syntax Tree), qui représente le code comme une structure de données abstraite. Pour cela, un analyseur écrit par l'un des créateurs de PHP (
php-parser ) est utilisé.
Le fonctionnement simplifiĂ© de l'outil peut ĂȘtre reprĂ©sentĂ© comme suit:
- Génération AST basée sur le code.
- L'utilisation de mutations appropriées (la liste complÚte est ici ).
- Créez un code modifié basé sur AST.
- Exécutez des tests par rapport au code modifié.
Par exemple, vous pouvez vérifier le mutateur de substitution moins plus:
<?php declare(strict_types=1); namespace Infection\Mutator\Arithmetic; use Infection\Mutator\Util\Mutator; use PhpParser\Node; use PhpParser\Node\Expr\Array_; final class Plus extends Mutator { public function mutate(Node $node) { return new Node\Expr\BinaryOp\Minus($node->left, $node->right, $node->getAttributes()); } protected function mutatesNode(Node $node): bool { if (!($node instanceof Node\Expr\BinaryOp\Plus)) { return false; } if ($node->left instanceof Array_ || $node->right instanceof Array_) { return false; } return true; } }
La méthode
mutate()
crée un nouvel élément, qui est remplacé par un plus. La classe
Node
est extraite du paquet php-parser; elle est utilisĂ©e pour les opĂ©rations AST et pour modifier le code PHP. Cependant, cette modification ne peut ĂȘtre appliquĂ©e nulle part, donc la mĂ©thode
mutatesNode()
contient des conditions supplémentaires. S'il y a un tableau à gauche du plus ou à droite du moins, le changement est inacceptable. Cette condition est utilisée à cause de ce code:
$tab = [0] + [1]; is correct, but the following one isn't correct. $tab = [0] - [1];
Résumé
Le test de mutation est un excellent outil qui complÚte le processus CI et vous permet d'évaluer la qualité des tests. La surbrillance verte des tests ne nous donne pas l'assurance que tout est bien écrit. Vous pouvez améliorer la précision des tests à l'aide de tests mutationnels - ou tests de test - ce qui augmente notre confiance dans les performances de la solution. Bien sûr, il n'est pas nécessaire de viser des résultats à 100% des métriques, car ce n'est pas toujours possible. Vous devez analyser les journaux et configurer les tests en conséquence.