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 { 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] ]; } }
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. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
De acordo com a infecção, nossos testes são precisos. No arquivo
per-mutator.md, podemos ver quais mutações foram usadas:
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.
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, []] ]; }
Após a execução, veremos o seguinte resultado:
You are running Infection with Xdebug enabled. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
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. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
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. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ /
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.htmlAlé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:
- Geração AST baseada em código.
- O uso de mutações adequadas (a lista completa está aqui ).
- Crie código modificado baseado em AST.
- 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_; 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; } }
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.