Introdução ao teste de mutação

Teste de Mutação (em francês: testes de mutação), descobri recentemente este termo descrevendo um processo capaz de detectar falhas em testes unitários, indo além da cobertura de código. Hoje, apresento a vocês essa abordagem que consiste em realizar esses testes manipulando o código.

Testes de unidade

Considerando que a utilidade do teste unitário está bem estabelecida, este assunto torna-se interessante se você estiver desenvolvendo um projeto testado, por mais importante que seja a cobertura do código.
Os testes unitários permitem destacar possíveis regressões causadas por uma modificação do código. Em teoria, se os testes validam o programa, significa que tudo está funcionando corretamente no aplicativo. Como a primeira e muitas vezes a única medida de confiança, usamos a cobertura de código. Quanto mais esta métrica se aproxima de 100%, mais nos assegura que nenhuma regressão passará despercebida. Infelizmente, essa afirmação permanece teórica.
Os testes, embora essenciais para a validação de uma aplicação qualitativa, são difíceis de demonstrar ou mesmo de apreciar a sua relevância.

Cobertura de código e cobertura de caso

Cobertura de código 100% não significa código 100% validado, mas apenas 100% desse código executado ao passar nos testes, nada mais.
A cobertura de código (linha, instrução, branch, etc.) mede apenas qual código foi executado pelos testes, sem garantia de detecção de defeitos. Ele só é capaz de identificar o código que ainda não foi testado.
Testar sem uma asserção é o exemplo óbvio porque, embora executado, o código não é realmente testado. Felizmente, esse cenário ainda é raro, o mais comum é encontrar código parcialmente testado pela suíte de testes. Um conjunto que testa apenas parcialmente o código que ainda pode executar todas as suas ramificações.
Em alguns casos, a cobertura do código não é um indicador de proteção. Aqui está um exemplo simples:

function isAdult(user) {
    return user.age >= 18;
}

Suponha que queremos verificar a idade de um usuário. Vamos escrever o código a seguir para ter certeza de que é importante.
Para testar este código podemos tentar com 12 e 38 como entrada. Esta ação seria suficiente para cobrir este código 100%.
O resultado seria o mesmo se omitíssemos considerar 18 como a maioria com este erro de digitação em nosso código:

function isAdult(user) {
    return user.age > 18;
}

…ou se só testamos o valor 12 anos, ou pior ainda se omitimos a afirmação em nosso teste.
O teste de mutação será realmente capaz de detectar se cada afirmação é testada de forma significativa. É a medida padrão para todos os outros tipos de cobertura.

Outros problemas no código

Vamos então supor que não queremos código desnecessário em nosso aplicativo. De fato, cada parte não testada será uma fonte de bug em potencial ou até mesmo complexidade adicional se não for essencial.
Veja por que o teste de mutação é uma ótima maneira de testar a relevância desse código:
if (someVariable !== null && someVariable.hasValue()) {}
Precisamos verificar o valor “nulo”? A condição foi adicionada por hábito? Isso pode significar que não temos certeza da variável”alguma variável” e justificaria uma análise mais aprofundada. Não podemos ir mais fundo sem perceber. O teste de mutação também nos ajuda com isso.

Testes de mutação: o que são?

Para detectar falhas em nossos testes unitários, existe uma solução: teste de mutação.

Esta técnica dá mais confiança em nossos testes. O teste de mutação é um conceito bastante simples. Seu princípio é maltratar o código-fonte, alterando-o para verificar se os testes associados falham de acordo. Falhas (ou mutações) são automaticamente semeadas em nosso código e, em seguida, os testes são executados. Se os testes falharem, a mutação é morta. Se os testes passarem, a mutação sobreviveu. Nesse caso, significa que os testes não correspondem à complexidade do código e deixam um ou mais de seus aspectos não testados. Então, a qualidade de nossos testes pode ser medida a partir da porcentagem de mutações mortas.
Em outras palavras, executamos os testes de unidade em versões modificadas automaticamente do código. Quando o código do aplicativo é alterado, ele deve produzir resultados diferentes e causar falhas nos testes de unidade. Se um teste de unidade não falhar nessa situação, isso pode indicar uma falha no conjunto de testes.
Aqui estão os passos para conseguir isso:

  • Execute o conjunto de testes usual para verificar se todos os testes passam em verde.
  • Modifique algumas partes do código testado antes de executar o conjunto de testes novamente.
  • Certifique-se de que os testes falharam conforme o esperado após modificar (mutar) o código testado.

Repita as etapas 2 e 3 enquanto as mutações possíveis permanecerem.
Vamos dar um exemplo concreto: pense em um mutante como uma classe adicional com apenas uma alteração do código original. Esta pode ser a mudança de um operador lógico em uma cláusula if como mostrado abaixo:
if( a || b ) {…} => if( a && b ) {…}
A detecção e rejeição de tal modificação por testes existentes é chamada de matar um mutante. Com um conjunto de testes perfeito, nenhum mutante de classe sobreviveria. Mas criar todos os mutantes possíveis consome muitos recursos, e é por isso que não é possível realizar essa abordagem manualmente em cenários reais.
Felizmente, existem ferramentas disponíveis para criar mutantes em tempo real e executar automaticamente todos os testes para cada um. A criação da transformação é baseada em um conjunto de operadores de mutação chamados para revelar erros típicos de programação. O operador de mutação usado para modificar o código acima é chamado de operador de condição.

Na prática

Esta técnica consiste, portanto, em duas partes: a geração de mutantes, depois a eliminação destes.
A geração de mutantes é a etapa de geração de classes mutantes a partir de classes de origem. Para começar, precisamos do código de negócios no qual queremos avaliar a relevância de nossos testes. Tomamos então um piscina de possíveis mutações, sendo uma mutação uma modificação do código-fonte, como a ação de substituir um operador por outro.
Aqui estão alguns exemplos:

  • + torna-se –
  • * torna-se /
  • >= se torna ==
  • verdadeiro se torna falso.
  • deletar uma instrução
  • etc.

Podemos modificar uma expressão aritmética e para |e| (ABS), altere um operador aritmético relacional para outro (ROR), altere um operador aritmético para outro (AOR), altere um operador booleano para outro (COR), altere uma expressão booleana/aritmética adicionando − ou ¬ ( UOI), modificar um nome de variável por outro, modificar um nome de variável por uma constante do mesmo tipo, modificar uma constante por outra constante do mesmo tipo...
A geração propriamente dita consiste em percorrer todas as instruções do código e para cada uma delas, determinar se as mutações são aplicáveis. Se assim for, cada mutação dará origem a um novo mutante.
Para a seguinte declaração:

if (a > 8) { x = y+1 }

Podemos considerar os seguintes mutantes:

 if (a < 8) { x = y+1 }
 if (a ≥ 8) { x = y+1 }
 if (a > 8) { x = y-1 }
 if (a > 8) { x = y }

Esse processo pode rapidamente se tornar intensivo em recursos. Quando o código a ser modificado contém um grande número de instruções e o " piscina » de mutações possíveis é significativo, então o número de mutantes gerados aumenta muito rapidamente.
Uma vez que o processo de geração de mutantes é concluído, os mutantes são armazenados até a próxima etapa: eliminação!
Para a segunda parte do processo, geramos muitos mutantes que não queremos que passem pelos testes; o objetivo será eliminar o maior número possível. Para isso, nossa arma será o aprimoramento dos testes unitários.

Balanço

Para um dado mutante, existem dois resultados possíveis, ou os testes são sempre verdes, ou pelo menos um deles ficou vermelho.

Normalmente queremos que os testes sejam verdes. Mas neste contexto, estamos procurando o vermelho. De fato, como vimos anteriormente, cada mutante deve falhar em pelo menos um dos testes de unidade. Se pelo menos um dos testes falhar, isso prova que eles são capazes de detectar modificações no código e, portanto, evitar possíveis bugs. Por outro lado, se todos os testes permanecerem verdes, o mutante sobreviverá, ficando invisível aos olhos de nossos testes.
Um mutante sobrevivente é, portanto, o sinal de um teste ausente!

Limitações

A análise completa do nosso código pode ser tediosa. Como vimos, o número de mutantes pode aumentar muito rapidamente.
Numa primeira fase, podemos por exemplo gerar 6000 mutantes. Durante a segunda fase de testes, mais de 98% deles serão eliminados, variando o percentual de acordo com a qualidade prévia de seus testes. Ainda temos 150 a 200 mutantes restantes.
Uma análise manual de cada um deles é demorada. Além disso, nossos testes unitários não são os únicos responsáveis ​​por sua sobrevivência. Pode aparecer um "mutante equivalente": um mutante que modifica a sintaxe do código-fonte, sem alterar sua semântica. Esse tipo de mutante impede que um teste de unidade o detecte.

while(...) {
    index++;
    if (index == 10)
    break;
}

Por exemplo, uma mutação de " == »Para« <= produzirá um mutante equivalente. Este exemplo terá a mesma condição de saída do loop.
Pré-análise da cobertura de código, criação de mutantes em tempo real e todos os testes necessários consomem muito tempo. Por exemplo, um código com 350 testes aumenta o tempo de execução em quatro em comparação com uma execução normal.
Dados esses números e por razões práticas, os testes de mutação não podem ser executados com tanta frequência quanto os testes unitários. Portanto, é importante encontrar um fluxo de trabalho adequado que ofereça o melhor compromisso em termos de eficiência. Para grandes sistemas de software, isso pode significar que o teste de mutação seria limitado a execuções noturnas.
Antes de implementá-los, você deve estar em uma abordagem de qualidade avançada. Os testes devem ser colocados no centro do desenvolvimento, para evitar resultados muito volumosos para serem analisados. No entanto, se a cobertura de código atingiu seus limites, essa pode ser uma boa abordagem para experimentar. Infelizmente, as ferramentas atuais não parecem suficientemente industrializadas.

Teste de mutação e javascript

Os testes de mutação são muito mais conhecidos e usados ​​no mundo do Java ou do PHP. No entanto, desde 2016 existe uma maneira de realizar testes de mutação em JavaScript graças ao Stryker Mutator. Há também testes de mutação do Grunt, cuja maioria do código-fonte está em processo de migração para o Stryker.
Segue o link do Github: http://stryker-mutator.github.io

Conclusão

Este artigo foi uma introdução rápida ao teste de mutação. Abordamos os mutantes de teste, apreciamos a relação direta entre a taxa de mutantes e a qualidade de um conjunto de testes existente e observamos a correlação com a cobertura de código.
Como a cobertura de código não é uma métrica muito confiável, Os testes de mutação são uma maneira rápida e fácil de medir a confiabilidade dos testes unitários. Promoveremos testes de mutação onde houver um problema real: o código de negócios.
Em suma, o teste de mutação parece ser uma boa adição a um conjunto de ferramentas de garantia de qualidade., com base em testes automatizados. Essa prática é bastante recente em JavaScript e ainda desconhecida. Será interessante ler as opiniões e comentários de usuários avançados.
Aurélie Ambal, (@Souvir) JS Artesã @JS-Republic
[actionbox color=”default” title=””descrição=”O treinamento JS-REPUBLIC é referenciado pelo Datadock.
Encontre todos os nossos cursos de treinamento em nosso site training.ux-republic.com:

  • Design de experiência do usuário
  • Ágil
  • JavaScript

” btn_label=”Nossos treinamentos” btn_link=”http://training.ux-republic.com” btn_color=”primary” btn_size=”big” btn_icon=”star” btn_external=”1″]