Teste de mutação: testes de teste


Os testes de escrita devem inspirar confiança na operação correta do código. Frequentemente, operamos com o grau de cobertura do código e, quando atingimos 100%, podemos dizer que a solução está correta. Você tem certeza disso? Talvez haja uma ferramenta que dê um feedback mais preciso?

Teste de mutação


Este termo descreve uma situação em que modificamos pequenos pedaços de código e vemos como isso afeta os testes. Se, após as alterações, os testes forem executados corretamente, isso indica que, para esses trechos de código, os testes não são suficientes. Obviamente, tudo depende do que exatamente estamos mudando, pois não precisamos testar todas as menores alterações, por exemplo, recuos ou nomes de variáveis, pois após elas os testes também devem ser concluídos corretamente. Portanto, nos testes de mutação, usamos os chamados mutadores (métodos modificadores), que substituem um pedaço de código por outro, mas de uma maneira que faça sentido. Falaremos sobre isso em mais detalhes abaixo. Às vezes, nós mesmos fazemos esses testes, verificando se os testes são interrompidos se alterarmos algo no código. Se refatorarmos "metade do sistema" e os testes ainda estiverem verdes, podemos dizer imediatamente que eles são ruins. E se alguém fez isso e os testes foram bons, então parabéns!

Estrutura infecciosa


Hoje em PHP, a estrutura de teste de mutação mais popular é a infecção . Ele suporta PHPUnit e PHPSpec, e para trabalhar com ele requer PHP 7.1+ e Xdebug ou phpdbg.

Primeiro lançamento e configuração


No primeiro início, vemos o configurador interativo da estrutura, que cria um arquivo especial com as configurações - infecção.json.dist. Parece algo como isto:

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

Timeout - uma opção cujo valor deve ser igual à duração máxima de um teste. Na source especificamos os diretórios dos quais alteraremos o código, você pode definir exceções. Nos logs existe uma opção de text , que definimos para coletar estatísticas apenas em testes errôneos, que é a mais interessante para nós. A opção perMutator permite salvar mutadores usados. Leia mais sobre isso na documentação.

Exemplo


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

Digamos que temos a classe acima. Vamos escrever um teste no 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] ]; } } 

Obviamente, esse teste precisa ser escrito antes de implementar o método add() . Ao executar ./vendor/bin/phpunit , obtemos:

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

Agora execute ./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 

De acordo com a infecção, nossos testes são precisos. No arquivo per-mutator.md, podemos ver quais mutações foram usadas:

 # 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 é uma simples mudança de sinal de mais para menos, o que deve interromper os testes. E o mutador PublicVisibility altera o modificador de acesso desse método, que também deve interromper os testes e, nesse caso, funciona.

Agora vamos adicionar um método mais 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, []] ]; } 

Após a execução, veremos o seguinte 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 

Nossos testes não estão bem. Primeiro, verifique o arquivo infecção.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: ==================== 

O primeiro problema não detectado é o uso da função array_values . É usado para redefinir chaves, porque array_filter retorna os valores com as chaves da matriz anterior. Além disso, em nossos testes, não há caso em que é necessário usar array_values , pois, caso contrário, uma matriz com os mesmos valores, mas chaves diferentes, é retornada.

O segundo problema está relacionado aos casos limítrofes. Em comparação, usamos o sinal > , mas não testamos nenhum caso de borda; portanto, substituir por >= não interrompe os testes. Você precisa adicionar apenas um teste:

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

E agora a infecção está feliz com tudo:

 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 

Adicione um método subtract à classe Calculator , mas sem um teste separado no PHPUnit:

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

E depois de executar a infecção, 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 

Desta vez, a ferramenta retornou duas mutações descobertas.

 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


Após cada execução, a ferramenta retorna três métricas:

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

Mutation Score Indicator - a porcentagem de mutações detectadas pelos testes.

A métrica é calculada da seguinte forma:

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

Mutation Code Coverage - A proporção de código coberto por mutações.

A métrica é calculada da seguinte forma:

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

Covered Code Mutation Score Indicator - determina a eficácia dos testes apenas para o código coberto por testes.

A métrica é calculada da seguinte forma:

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

Use em projetos mais complexos


No exemplo acima, há apenas uma classe, portanto executamos Infecção sem parâmetros. Porém, no trabalho diário de projetos comuns, será útil usar o parâmetro –filter , que permite especificar o conjunto de arquivos aos quais queremos aplicar mutações.

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

Falsos positivos


Algumas mutações não afetam o desempenho do código e o Infection retorna um MSI abaixo de 100%. Mas nem sempre podemos fazer algo com isso, então temos que aceitar essas situações. Algo semelhante é mostrado neste exemplo:

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

Obviamente, aqui o método getRatio não faz sentido; em um projeto normal, provavelmente haveria algum tipo de cálculo. Mas o resultado pode ser 1 . Retorno de infecção:

 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 e dividir por 1 retorna o mesmo resultado, igual ao número original. Portanto, essa mutação não deve interromper os testes e, apesar da dissecção da infecção com relação à precisão de nossos testes, tudo está em ordem.

Otimização para grandes projetos


Nos casos de grandes projetos, a infecção pode levar muito tempo. Você pode otimizar a execução durante o IC se processar apenas arquivos modificados.Para obter mais informações, consulte a documentação: https://infection.imtqy.com/guide/how-to.html

Além disso, você pode executar testes no código modificado em paralelo. No entanto, isso só é possível se todos os testes forem independentes. Ou seja, esses devem ser bons testes. Para habilitar esta opção, use a –threads :

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

Como isso funciona?


A estrutura de infecção usa AST (Abstract Syntax Tree), que representa o código como uma estrutura de dados abstrata. Para isso, é usado um analisador escrito por um dos criadores do PHP ( php-parser ).

A operação simplificada da ferramenta pode ser representada da seguinte forma:

  1. Geração AST baseada em código.
  2. O uso de mutações adequadas (a lista completa está aqui ).
  3. Crie código modificado baseado em AST.
  4. Execute testes em relação ao código alterado.

Por exemplo, você pode verificar o menos um mutador de substituição mais:

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

O método mutate() cria um novo elemento, que é substituído por um plus. A classe Node é retirada do pacote php-parser; é usada para operações AST e para modificar o código PHP. No entanto, essa alteração não pode ser aplicada em nenhum lugar; portanto, o método mutatesNode() contém condições adicionais. Se a matriz estiver à esquerda do sinal de mais ou à direita do sinal de menos, a alteração será inaceitável. Esta condição é usada devido a este código:

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

Sumário


O teste de mutação é uma excelente ferramenta que complementa o processo de IC e permite avaliar a qualidade dos testes. O destaque verde dos testes não nos dá confiança de que tudo está bem escrito. Você pode melhorar a precisão dos testes usando testes mutacionais - ou testes de teste - o que aumenta nossa confiança no desempenho da solução. Obviamente, não é necessário buscar resultados 100% das métricas, porque isso nem sempre é possível. Você precisa analisar os logs e configurar os testes de acordo.

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


All Articles