Como avaliar a qualidade dos testes? Muitos confiam na métrica mais popular conhecida por todos - cobertura de código. Mas essa é uma métrica quantitativa, não qualitativa. Ele mostra quanto do seu código é coberto por testes, mas não o quão bem esses testes são gravados.
Uma maneira de descobrir isso é através do teste de mutação. Essa ferramenta, fazendo pequenas alterações no código-fonte e executando novamente os testes depois disso, permite identificar testes inúteis e cobertura de baixa qualidade.
No
Meetup de PHP do
Badoo, em março, falei sobre como organizar o teste mutacional para código PHP e quais problemas você pode encontrar. O vídeo está disponível
aqui e, para a versão em texto, seja bem-vindo ao gato.

O que é teste de mutação
Para explicar o que quero dizer, mostrarei alguns exemplos. Eles são simples, exagerados em alguns lugares e podem parecer óbvios (embora exemplos reais sejam geralmente bastante complexos e não possam ser vistos com os olhos).
Considere a situação: temos uma função elementar que afirma ser um adulto e há um teste que a testa. O teste possui um dataProvider, ou seja, testa dois casos: 17 anos e 19 anos. Para muitos de vocês, é óbvio que o adulto tem 100% de cobertura. A única linha. É realizado por um teste. Tudo é maravilhoso.

Porém, um exame mais detalhado revela que nosso fornecedor é mal escrito e não testa condições de contorno: a idade de 18 anos como condição de contorno não é testada. Você pode substituir o sinal> por> =, e o teste não detectará essa alteração.
Outro exemplo, um pouco mais complicado. Existe uma função que cria algum objeto simples contendo setters e getters. Temos três campos que definimos e há um teste que verifica se a função buildPromoBlock realmente coleta o objeto que esperamos.

Se você observar atentamente, também definimos setSomething, que define algumas propriedades como true. Mas no teste não temos essa afirmação. Ou seja, podemos remover esta linha do buildPromoBlock - e nosso teste não detectará essa alteração. Ao mesmo tempo, temos 100% de cobertura na função buildPromoBlock, porque todas as três linhas foram executadas durante o teste.
Esses dois exemplos nos levam ao que é o teste de mutação.
Antes de desmontar o algoritmo, darei uma breve definição. O teste de mutação é um mecanismo que nos permite, fazendo pequenas alterações no código, imitar as ações do maligno Pinóquio ou o jovem Vasya, que veio e começou a quebrá-lo intencionalmente, substituindo os sinais> por <, = by! =, E assim por diante. Para cada alteração que fazemos para bons propósitos, executamos testes que devem cobrir a linha alterada.
Se os testes não nos mostraram nada, se não caíram, provavelmente não são eficazes o suficiente. Eles não testam casos de fronteira, não contêm afirmações: talvez precisem ser melhorados. Se os testes caírem, eles são legais. Eles realmente protegem contra essas mudanças. Portanto, nosso código é mais difícil de quebrar.
Agora vamos analisar o algoritmo. É bem simples A primeira coisa que fazemos para realizar o teste de mutação é pegar o código fonte. Em seguida, obtemos cobertura de código para saber quais testes executar para qual sequência. Depois disso, revisamos o código fonte e geramos os chamados mutantes.
Um mutante é uma alteração de código único. Ou seja, assumimos uma certa função em que havia um sinal> em comparação, se, alteramos esse sinal para> = - e obtemos um mutante. Depois disso, executamos os testes. Aqui está um exemplo de uma mutação (substituímos> por> =):

Nesse caso, as mutações não são feitas aleatoriamente, mas de acordo com certas regras. A resposta do teste de mutação é idempotente. Não importa quantas vezes executemos testes mutacionais no mesmo código, ele produz os mesmos resultados.
A última coisa que fazemos é executar os testes que cobrem a linha mutada. Tire isso da cobertura. Existem ferramentas não ideais que conduzem todos os testes. Mas uma boa ferramenta afastará apenas as necessárias.
Depois disso, avaliamos o resultado. Os testes caíram - então está tudo bem. Se eles não caíram, então eles não são muito eficazes.
Métricas
Quais métricas os testes de mutação nos fornecem? Ele adiciona mais três à cobertura do código, sobre o qual falaremos agora.
Mas primeiro, vamos analisar a terminologia.

Existe o conceito de mutantes mortos: esses são os mutantes que nossos testes “pregaram” (ou seja, eles os pegaram).

Existe o conceito de mutante escapado (mutantes sobreviventes). Estes são os mutantes que conseguiram evitar a punição (ou seja, os testes não os pegaram).

E há conceitos cobertos de mutante - um mutante coberto por testes e um mutante descoberto oposto a ele, que não é coberto por nenhum teste (isto é, temos código, tem lógica de negócios, podemos alterá-lo, mas não um único teste) não verifica alterações).
O principal indicador que o teste de mutação nos fornece é o MSI (indicador de pontuação na mutação), a razão entre o número de mutantes mortos e o número total deles.
O segundo indicador é a cobertura do código de mutação. É apenas qualitativo, não quantitativo, porque mostra quanta lógica de negócios você pode quebrar e faz isso regularmente, nossos testes são capturados.
E a última métrica é MSI coberto, isto é, um MSI mais suave. Nesse caso, calculamos o MSI apenas para os mutantes que foram cobertos por testes.
Problemas de teste de mutação
Por que menos da metade dos programadores ouviu falar sobre essa ferramenta? Por que não é usado em todos os lugares?
Baixa velocidade
O primeiro problema (um dos principais) é a velocidade do teste de mutação. No código, se tivermos dezenas de operadores de mutação, mesmo para a classe mais simples, podemos gerar centenas de mutações. Para cada mutação, você precisará executar testes. Se tivermos, digamos, 5.000 testes de unidade que são executados por dez minutos, o teste mutacional pode levar horas.
O que pode ser feito para nivelar isso? Execute testes em paralelo, em vários threads. Jogue fluxos em vários carros. Isso funciona.
A segunda maneira é execuções incrementais. Não há necessidade de contar indicadores mutacionais para toda a ramificação de cada vez - você pode obter diferenças de ramificação. Se você usar brunches de recursos, será fácil fazer isso: execute testes apenas nos arquivos que foram alterados e veja o que está acontecendo no assistente, compare, analise.
A próxima coisa que você pode fazer é ajustar a mutação. Como os operadores de mutação podem ser alterados, você pode definir certas regras pelas quais eles funcionam, e pode interromper algumas mutações se elas conscientemente levarem a problemas.
Um ponto importante: o teste mutacional é adequado apenas para testes de unidade. Apesar de poder ser executado para testes de integração, essa é obviamente uma idéia falha, porque os testes de integração (como de ponta a ponta) são muito mais lentos e afetam muito mais código. Você simplesmente nunca esperará pelos resultados. Em princípio, esse mecanismo foi inventado e desenvolvido exclusivamente para testes de unidade.
Mutantes sem fim
O segundo problema que pode surgir com os testes de mutação são os chamados mutantes sem fim. Por exemplo, há um código simples, um loop for simples:

Se você substituir i ++ por i--, o ciclo se tornará infinito. Seu código permanecerá por um longo tempo. E o teste mutacional geralmente gera essas mutações.
A primeira coisa que você pode fazer é ajustar a mutação. Obviamente, mudar o i ++ para o i-- em um loop for é uma péssima idéia: em 99% dos casos, acabaremos com um loop infinito. Portanto, proibimos fazer isso em nossa ferramenta.
A segunda e mais importante coisa que protege você de tais problemas é o tempo limite para a execução. Por exemplo, o mesmo PHPUnit tem a capacidade de concluir um teste de tempo limite, independentemente de onde estiver parado. O PHPUnit através do PCNTL desliga os retornos de chamada e calcula o tempo em si. Se o teste falhar por um determinado período, ele simplesmente é corrigido e esse caso é considerado um mutante morto, porque o código que gerou as mutações é realmente verificado pelo teste, o que realmente captura o problema, indicando que o código ficou inoperante.
Mutantes idênticos
Esse problema existe na teoria do teste de mutação. Na prática, eles não o encontram com muita frequência, mas você precisa saber sobre isso.
Considere um exemplo clássico que ilustra isso. Temos uma multiplicação da variável A por -1 e divisão de A por -1. No caso geral, essas operações levam ao mesmo resultado. Mudamos o sinal de A. Conseqüentemente, temos uma mutação que permite que dois sinais mudem entre si. A lógica do programa por essa mutação não é violada. Testes e não deve pegá-lo, não deve cair. Devido a esses mutantes idênticos, algumas dificuldades surgem.
Não há solução universal - todos resolvem esse problema à sua maneira. Talvez algum tipo de sistema de registro de mutantes ajude. Nós do Badoo estamos pensando em algo semelhante agora, vamos imitá-los.
Isto é uma teoria. E o PHP?
Existem duas ferramentas conhecidas para testes mutacionais: Humbug e Infection. Quando eu estava preparando o artigo, queria falar sobre qual é o melhor e chegar à conclusão de que isso é infecção.
Mas quando fui para a página do Humbug, vi o seguinte: O Humbug se declarou obsoleto em favor da Infecção. Portanto, parte do meu artigo acabou sendo sem sentido. Portanto, a infecção é uma ferramenta muito boa. Devo agradecer ao
borNfree de Minsk, que o criou. Ele realmente funciona bem. Você pode pegá-lo diretamente da caixa, colocá-lo no compositor e iniciá-lo.
Nós realmente gostamos de Infecção. Nós queríamos usá-lo. Mas eles não podiam por dois motivos. A infecção requer cobertura de código para executar testes de mutantes correta e precisamente. Aqui temos duas maneiras. Podemos calculá-lo diretamente em tempo de execução (mas temos 100.000 testes de unidade). Ou podemos calculá-lo para o mestre atual (mas construir em nossa nuvem de dez máquinas muito poderosas em vários threads leva uma hora e meia). Se fizermos isso a cada execução mutacional, a ferramenta provavelmente não funcionará.
Existe uma opção para alimentar o final, mas no formato PHPUnit, esse é um monte de arquivos XML. Além do fato de conterem informações valiosas, eles arrastam um monte de estrutura, alguns colchetes e outras coisas. Imaginei que, em geral, nossa cobertura de código pesaria cerca de 30 GB e precisamos arrastá-la por todas as máquinas em nuvem, lidas constantemente a partir do disco. Em geral, a idéia é mais ou menos.
O segundo problema foi ainda mais significativo. Temos uma maravilhosa biblioteca
SoftMocks . Ele nos permite lidar com o código legado, difícil de testar, e escrever com êxito testes para ele. Estamos usando-o ativamente e não o recusaremos no futuro próximo, apesar de estarmos escrevendo um novo código para não precisar do SoftMocks. Portanto, essa biblioteca é incompatível com o Infection, porque eles usam quase a mesma abordagem para alterar alterações.
Como o SoftMocks funciona? Eles interceptam inclusões de arquivos e as substituem por modificadas, ou seja, em vez de executar a classe A, os SoftMocks criam a classe A em um local diferente e conectam outra em vez da original. A infecção age exatamente da mesma maneira, mas funciona através de
stream_wrapper_register () , que faz a mesma coisa, mas no nível do sistema. Como resultado, SoftMocks ou Infection podem funcionar para nós. Como o SoftMocks é necessário para nossos testes, é muito difícil tornar essas duas ferramentas amigas. Provavelmente isso é possível, mas, neste caso, entramos tanto em Infecção que o significado dessas mudanças é simplesmente perdido.
Superando dificuldades, escrevemos nosso pequeno instrumento. Emprestamos operadores de mutação da Infection (eles são escritos de maneira legal e muito fáceis de usar). Em vez de iniciar as mutações por meio de stream_wrapper_register (), as executamos pelo SoftMocks, ou seja, usamos nossa ferramenta da caixa. Nossa ferramenta é amiga do nosso serviço de cobertura de código interno. Ou seja, sob demanda, ele pode receber cobertura para um arquivo ou linha sem executar todos os testes, o que acontece muito rapidamente. No entanto, é simples. Se o Infection tiver vários tipos de ferramentas e recursos (por exemplo, iniciando em vários threads), o nosso não terá nada parecido. Mas usamos nossa infraestrutura interna para compensar essa falha. Por exemplo, executamos o mesmo teste em vários segmentos através da nossa nuvem.
Como usamos isso?
O primeiro é uma execução manual. Esta é a primeira coisa a fazer. Todos os testes que você escreve são verificados manualmente pelo teste de mutação. Parece algo como isto:

Fiz um teste de mutação para algum arquivo. Obteve o resultado: 16 mutantes. Desses, 15 foram mortos por testes e um caiu com um erro. Eu não disse que mutações podem gerar fatalidades. Podemos facilmente mudar algo: invalidar o tipo de retorno ou outra coisa. Isso é possível, é considerado um mutante morto, porque nosso teste começará a cair.
No entanto, a infecção distingue esses mutantes em uma categoria separada pelo motivo que às vezes vale a pena prestar atenção especial aos erros. Acontece que algo estranho acontece - e o mutante não é considerado corretamente morto.
A segunda coisa que usamos é o relatório sobre o mestre. Uma vez por dia, à noite, quando nossa infraestrutura de desenvolvimento está ociosa, geramos um relatório de cobertura de código. Depois disso, fazemos o mesmo relatório de teste de mutação. É assim:

Se você já viu o relatório sobre a cobertura de código do PHPUnit, provavelmente percebeu que a interface é semelhante, porque criamos nossa ferramenta por analogia. Ele simplesmente calculou todos os principais indicadores de um arquivo específico em um diretório. Também estabelecemos certas metas (na verdade, as retiramos do teto e ainda não as cumprimos, pois ainda não decidimos quais metas devem ser guiadas por cada métrica, mas elas existem para facilitar a criação de relatórios no futuro).
E a última coisa, a mais importante, que é uma consequência das outras duas. Programadores são pessoas preguiçosas. Sou preguiçoso: gosto de tudo para trabalhar e não preciso fazer gestos extras. Fizemos isso para que, quando um desenvolvedor forneça sua própria ramificação, os indicadores de sua ramificação e mestre de brunch sejam automaticamente contados incrementalmente.

Por exemplo, executei dois arquivos e obtive esse resultado. No mestre, eu tinha 548 mutantes, 400 foram mortos, segundo outro arquivo - 147 contra 63. No meu ramo, o número de mutantes nos dois casos aumentou. Mas no primeiro arquivo, o mutante foi pregado e, no segundo, ele escapou. Naturalmente, o indicador MSI caiu. Isso permite que mesmo as pessoas que não desejam perder tempo realizem testes mutacionais com as próprias mãos, vejam o que fizeram pior e prestem atenção a ele (exatamente da mesma maneira que os revisores fazem no processo de revisão de código).
Resultados
Ainda é difícil fornecer números: não tínhamos nenhum indicador, agora ele apareceu, mas não há nada com o que comparar.
Posso dizer que o teste mutacional dá em termos de efeito psicológico. Se você começa a executar seus testes através de testes de mutação, involuntariamente começa a escrever testes melhores, e escrever testes de qualidade inevitavelmente leva a uma mudança na maneira como você escreve código - você começa a pensar que precisa cobrir todos os casos em que pode quebrar, você o inicia. melhor estrutura, torná-lo mais testável.
Esta é uma opinião exclusivamente subjetiva. Mas alguns de meus colegas deram o mesmo feedback: quando começaram a usar constantemente testes mutacionais em seu trabalho, começaram a escrever testes melhor e muitos disseram que começaram a escrever melhor código.
Conclusões
A cobertura do código é uma métrica importante que precisa ser monitorada. Mas este indicador não garante nada: não significa que você está seguro.
O teste de mutação pode ajudar a melhorar seus testes de unidade e a cobertura do código de rastreamento faz sentido. Já existe uma ferramenta para PHP, portanto, se você tiver um projeto pequeno sem problemas, então pegue e experimente hoje.
Comece pelo menos executando um teste de mutação manualmente. Dê este simples passo e veja o que ele oferece. Tenho certeza que você vai gostar.