Test de mutation: test des tests


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 { /** * @var Calculator */ private $calculator; public function setUp(): void { $this->calculator = new Calculator(); } /** * @dataProvider additionProvider */ 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. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / / /___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/ Running initial test suite... PHPUnit version: 8.2.2 9 [============================] 1 sec Generate mutants... Processing source code files: 1/1Creating mutated files and processes: 0/2 Creating mutated files and processes: 2/2 .: killed, M: escaped, S: uncovered, E: fatal error, T: timed out .. (2 / 2) 2 mutations were generated: 2 mutants were killed 0 mutants were not covered by tests 0 covered mutants were not detected 0 errors were encountered 0 time outs were encountered Metrics: Mutation Score Indicator (MSI): 100% Mutation Code Coverage: 100% Covered Code MSI: 100% Please note that some mutants will inevitably be harmless (ie false positives). Time: 1s. Memory: 10.00MB 

Selon Infection, nos tests sont précis. Dans le fichier per-mutator.md, nous pouvons voir quelles mutations ont été utilisées:

 # Effects per Mutator | Mutator | Mutations | Killed | Escaped | Errors | Timed Out | MSI | Covered MSI | | ------- | --------- | ------ | ------- |------- | --------- | --- | ----------- | | Plus | 1 | 1 | 0 | 0 | 0 | 100| 100| | PublicVisibility | 1 | 1 | 0 | 0 | 0 | 100| 100| 

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.

 /** * @param int[] $numbers */ public function findGreaterThan(array $numbers, int $threshold): array { return \array_values(\array_filter($numbers, static function (int $number) use ($threshold) { return $number > $threshold; })); } /** * @dataProvider findGreaterThanProvider */ 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. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / / /___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/ Running initial test suite... PHPUnit version: 8.2.2 11 [============================] < 1 sec Generate mutants... Processing source code files: 1/1Creating mutated files and processes: 0/7 Creating mutated files and processes: 7/7 .: killed, M: escaped, S: uncovered, E: fatal error, T: timed out ..M..M. (7 / 7) 7 mutations were generated: 5 mutants were killed 0 mutants were not covered by tests 2 covered mutants were not detected 0 errors were encountered 0 time outs were encountered Metrics: Mutation Score Indicator (MSI): 71% Mutation Code Coverage: 100% Covered Code MSI: 71% Please note that some mutants will inevitably be harmless (ie false positives). Time: 1s. Memory: 10.00MB 

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. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / / /___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/ Running initial test suite... PHPUnit version: 8.2.2 12 [============================] < 1 sec Generate mutants... Processing source code files: 1/1Creating mutated files and processes: 0/7 Creating mutated files and processes: 7/7 .: killed, M: escaped, S: uncovered, E: fatal error, T: timed out ....... (7 / 7) 7 mutations were generated: 7 mutants were killed 0 mutants were not covered by tests 0 covered mutants were not detected 0 errors were encountered 0 time outs were encountered Metrics: Mutation Score Indicator (MSI): 100% Mutation Code Coverage: 100% Covered Code MSI: 100% Please note that some mutants will inevitably be harmless (ie false positives). Time: 1s. Memory: 10.00MB 

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. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / / /___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/ Running initial test suite... PHPUnit version: 8.2.2 11 [============================] < 1 sec Generate mutants... Processing source code files: 1/1Creating mutated files and processes: 0/9 Creating mutated files and processes: 9/9 .: killed, M: escaped, S: uncovered, E: fatal error, T: timed out .......SS (9 / 9) 9 mutations were generated: 7 mutants were killed 2 mutants were not covered by tests 0 covered mutants were not detected 0 errors were encountered 0 time outs were encountered Metrics: Mutation Score Indicator (MSI): 77% Mutation Code Coverage: 77% Covered Code MSI: 100% Please note that some mutants will inevitably be harmless (ie false positives). Time: 1s. Memory: 10.00MB 

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.html

De 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:

  1. Génération AST basée sur le code.
  2. L'utilisation de mutations appropriées (la liste complÚte est ici ).
  3. Créez un code modifié basé sur AST.
  4. 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_; /** * @internal */ final class Plus extends Mutator { /** * Replaces "+" with "-" * @param Node&Node\Expr\BinaryOp\Plus $node * @return Node\Expr\BinaryOp\Minus */ 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.

Source: https://habr.com/ru/post/fr457888/


All Articles