Prueba de mutación: pruebas de prueba


Escribir pruebas debe inspirar confianza en el correcto funcionamiento del código. A menudo operamos en el grado de cobertura del código, y cuando alcanzamos el 100%, podemos decir que la solución es correcta. ¿Estás seguro de esto? ¿Quizás hay una herramienta que dará retroalimentación más precisa?

Prueba de mutación


Este término describe una situación en la que modificamos pequeños fragmentos de código y vemos cómo esto afecta las pruebas. Si, después de los cambios, las pruebas se realizan correctamente, esto indica que para estas piezas de código las pruebas no son suficientes. Por supuesto, todo depende de qué estamos cambiando exactamente, porque no necesitamos probar todos los cambios más pequeños, por ejemplo, sangrías o nombres de variables, ya que después de ellos las pruebas también deben completarse correctamente. Por lo tanto, en las pruebas de mutación, utilizamos los llamados mutadores (métodos modificadores), que reemplazan una pieza de código con otra, pero de una manera que tiene sentido. Hablaremos de esto con más detalle a continuación. A veces realizamos tales pruebas nosotros mismos, verificando si las pruebas se rompen si cambiamos algo en el código. Si refactorizamos "la mitad del sistema" y las pruebas siguen siendo verdes, entonces podemos decir de inmediato que son malas. Y si alguien hizo esto y las pruebas fueron buenas, ¡felicidades!

Marco infeccioso


Hoy en PHP, el marco de prueba de mutaciones más popular es la infección . Es compatible con PHPUnit y PHPSpec, y requiere PHP 7.1+ y Xdebug o phpdbg para funcionar con él.

Primer lanzamiento y configuración


En el primer inicio, vemos el configurador interactivo del marco, que crea un archivo especial con la configuración - infection.json.dist. Se parece a esto:

{ "timeout": 10, "source": { "directories": [ "src" }, "logs": { "text": "infection.log", "perMutator": "per-mutator.md" }, "mutators": { "@default": true } 

Timeout : una opción cuyo valor debe ser igual a la duración máxima de una prueba. En source especificamos los directorios desde los que mutaremos el código, puede establecer excepciones. En los logs hay una opción de text , que configuramos para recopilar estadísticas solo en pruebas erróneas, que es la más interesante para nosotros. La opción perMutator permite guardar mutadores usados. Lea más sobre esto en la documentación.

Ejemplo


 final class Calculator { public function add(int $a, int $b): int { return $a + $b; } } 

Digamos que tenemos la clase anterior. Escribamos una prueba 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] ]; } } 

Por supuesto, esta prueba debe escribirse antes de implementar el método add() . Al ejecutar ./vendor/bin/phpunit obtenemos:

 PHPUnit 8.2.2 by Sebastian Bergmann and contributors. .... 4 / 4 (100%) Time: 39 ms, Memory: 4.00 MB OK (4 tests, 4 assertions) 

Ahora ejecute ./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 

Según Infection, nuestras pruebas son precisas. En el archivo per-mutator.md, podemos ver qué mutaciones se usaron:

 # 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 es un simple cambio de signo de más a menos, que debería romper las pruebas. Y el mutador PublicVisibility cambia el modificador de acceso de este método, que también debería romper las pruebas, y en este caso funciona.

Ahora agreguemos un método más complicado.

 /** * @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, []] ]; } 

Después de la ejecución, veremos el siguiente resultado:

 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 

Nuestras pruebas no están bien. Primero, verifique el archivo 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: ==================== 

El primer problema no descubierto es el uso de la función array_values . Se utiliza para restablecer claves, porque array_filter devuelve los valores con claves de la matriz anterior. Además, en nuestras pruebas no hay ningún caso en el que sea necesario usar array_values , ya que de lo contrario se array_values una matriz con los mismos valores pero claves diferentes.

El segundo problema está relacionado con los casos límite. En comparación, usamos el signo > , pero no probamos ningún caso límite, por lo que reemplazarlo con >= no interrumpe las pruebas. Solo necesita agregar una prueba:

 public function findGreaterThanProvider(): array { return [ [[1, 2, 3], -1, [1, 2, 3]], [[-2, -3, -4], 0, []], [[4, 5, 6], 4, [5, 6]] ]; } 

Y ahora Infección está feliz con todo:

 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 

Agregue un método de subtract a la clase Calculator , pero sin una prueba separada en PHPUnit:

 public function subtract(int $a, int $b): int { return $a - $b; } 

Y después de ejecutar Infección vemos:

 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 

Esta vez, la herramienta devolvió dos mutaciones descubiertas.

 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; } 

Métricas


Después de cada ejecución, la herramienta devuelve tres métricas:

 Metrics: Mutation Score Indicator (MSI): 47% Mutation Code Coverage: 67% Covered Code MSI: 70% 

Mutation Score Indicator : el porcentaje de mutaciones detectadas por las pruebas.

La métrica se calcula de la siguiente manera:

 TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount; MSI = (TotalDefeatedMutants / TotalMutantsCount) * 100; 

Mutation Code Coverage del código de mutación: la proporción de código cubierto por mutaciones.

La métrica se calcula de la siguiente manera:

 TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount; CoveredRate = (TotalCoveredByTestsMutants / TotalMutantsCount) * 100; 

Covered Code Mutation Score Indicator : determina la efectividad de las pruebas solo para el código que está cubierto por las pruebas.

La métrica se calcula de la siguiente manera:

 TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount; TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount; CoveredCodeMSI = (TotalDefeatedMutants / TotalCoveredByTestsMutants) * 100; 

Uso en proyectos más complejos.


En el ejemplo anterior, solo hay una clase, por lo que ejecutamos Infección sin parámetros. Pero en el trabajo diario en proyectos ordinarios será útil usar el parámetro –filter , que le permite especificar el conjunto de archivos a los que queremos aplicar mutaciones.

 ./vendor/bin/infection --filter=Calculator.php 

Falsos positivos


Algunas mutaciones no afectan el rendimiento del código, y la infección devuelve un MSI por debajo del 100%. Pero no siempre podemos hacer algo con esto, por lo que tenemos que aceptar estas situaciones. Algo similar se muestra en este ejemplo:

 public function calcNumber(int $a): int { return $a / $this->getRatio(); } private function getRatio(): int { return 1; } 

Por supuesto, aquí el método getRatio no tiene sentido, en un proyecto normal, probablemente habría algún tipo de cálculo. Pero el resultado podría ser 1 . La infección regresa:

 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 

Como sabemos, multiplicar y dividir por 1 devuelve el mismo resultado, igual al número original. Por lo tanto, esta mutación no debe romper las pruebas, y a pesar de la disección de la infección con respecto a la precisión de nuestras pruebas, todo está en orden.

Optimización para grandes proyectos.


En casos con proyectos grandes, la infección puede llevar mucho tiempo. Puede optimizar la ejecución durante CI si solo procesa archivos modificados. Para obtener más información, consulte la documentación: https://infection.imtqy.com/guide/how-to.html

Además, puede ejecutar pruebas en el código modificado en paralelo. Sin embargo, esto solo es posible si todas las pruebas son independientes. A saber, tales deberían ser buenas pruebas. Para habilitar esta opción, use la –threads :

 ./vendor/bin/infection --threads=4 

Como funciona


El marco de Infección utiliza AST (Árbol de sintaxis abstracta), que representa el código como una estructura de datos abstracta. Para esto, se utiliza un analizador escrito por uno de los creadores de PHP ( php-parser ).

El funcionamiento simplificado de la herramienta se puede representar de la siguiente manera:

  1. Generación AST basada en código.
  2. El uso de mutaciones adecuadas (la lista completa está aquí ).
  3. Crear código modificado basado en AST.
  4. Ejecute pruebas en relación con el código modificado.

Por ejemplo, puede verificar el mutante de sustitución negativa más:

 <?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; } } 

El método mutate() crea un nuevo elemento, que se reemplaza por un plus. La clase Node se toma del paquete php-parser; se usa para operaciones AST y para modificar código PHP. Sin embargo, este cambio no se puede aplicar en ninguna parte, por lo que el método mutatesNode() contiene condiciones adicionales. Si hay una matriz a la izquierda del signo más o a la derecha del signo menos, entonces el cambio es inaceptable. Esta condición se usa debido a este código:

 $tab = [0] + [1]; is correct, but the following one isn't correct. $tab = [0] - [1]; 

Resumen


La prueba de mutación es una herramienta excelente que complementa el proceso de CI y le permite evaluar la calidad de las pruebas. El resaltado verde de las pruebas no nos da la confianza de que todo esté bien escrito. Puede mejorar la precisión de las pruebas utilizando pruebas mutacionales, o pruebas de prueba, lo que aumenta nuestra confianza en el rendimiento de la solución. Por supuesto, no es necesario esforzarse por obtener resultados 100% de las métricas, porque esto no siempre es posible. Debe analizar los registros y configurar las pruebas en consecuencia.

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


All Articles