Mutation Testing: seus testes realmente conseguiriam encontrar um bug?

Cobertura de código responde “esse código foi executado pelos testes”. Não responde a pergunta que realmente importa: se aquele código tivesse um bug ali, os testes teriam percebido? Mutation testing existe especificamente para responder essa segunda pergunta, e a resposta costuma ser mais desconfortável do que o número de cobertura sozinho sugere.

O que um mutante é, na prática

Mutation testing funciona introduzindo pequenas alterações propositais no código de produção, chamadas mutantes: trocar um > por um >=, inverter uma condição booleana, substituir o corpo de um método por um retorno vazio. Depois de cada mutação, a suíte de testes inteira roda contra essa versão alterada. Se pelo menos um teste falhar, o mutante foi “morto” (killed), prova de que a suíte detectaria aquela mudança de comportamento. Se todos os testes continuarem passando, o mutante “sobreviveu” (survived), o que significa que aquela parte do código poderia ter um bug exatamente daquele tipo sem nenhum teste perceber.

Um exemplo real e ilustrativo desse tipo de sobrevivência: um método Put() teve o corpo inteiro substituído por um retorno vazio, e mesmo assim nenhum teste da suíte falhou, revelando que aquele método específico não tinha nenhuma asserção realmente verificando seu efeito.

Mutation Score: a métrica que cobertura não fornece

Mutation Score é calculado como a proporção de mutantes mortos sobre o total de mutantes gerados, multiplicado por cem. Diferente de cobertura, que mede execução, mutation score mede efetividade de detecção: um mutation score de 60% significa que 40% das pequenas alterações propositais introduzidas no código passaram despercebidas por toda a suíte de testes, um sinal direto de teste que executa código sem verificar comportamento relevante.

Rodando com Stryker.NET

Stryker.NET é a ferramenta de referência no ecossistema .NET para mutation testing, configurável via um arquivo indicando projeto de teste, projeto de produção a mutar, e limiares de qualidade esperados:

{
  "stryker-config": {
    "project": "MinhaAplicacao.csproj",
    "test-projects": ["MinhaAplicacao.Tests.csproj"],
    "thresholds": { "high": 90, "low": 60, "break": 30 },
    "reporters": ["html", "progress"]
  }
}

Os limiares definem faixas de interpretação do resultado: acima de high, considerado bom; entre low e high, aceitável com ressalva; abaixo de break, falha a execução (útil para travar um pipeline de integração contínua quando o mutation score cai abaixo de um piso aceitável).

O custo: tempo, e bastante dele

Mutation testing é significativamente mais lento que rodar a suíte de testes normalmente, porque a suíte inteira precisa rodar de novo para cada mutante gerado, e um código base de tamanho moderado facilmente gera centenas de mutantes. Isso torna inviável rodar mutation testing a cada commit da mesma forma que se roda a suíte de testes comum. O uso mais realista é periódico, ou direcionado a módulos específicos considerados críticos, em vez de aplicado indiscriminadamente ao projeto inteiro a cada mudança.

Mutation testing não substitui cobertura, expõe o que ela esconde

Cobertura de código e mutation testing respondem perguntas complementares, não concorrentes: cobertura mostra que código nunca foi executado por nenhum teste, uma informação útil e barata de obter. Mutation testing mostra que código foi executado, mas sem verificação suficiente para pegar um bug real ali, o que é exatamente o ponto cego que cobertura sozinha deixa passar despercebido.

A pergunta que mutation score força a responder

Depois de rodar mutation testing pela primeira vez num projeto que já reportava cobertura alta, a pergunta que sobra não é mais “quanto do código os testes tocaram”. É “quantos desses testes realmente teriam pego um bug se ele existisse ali”. Para boa parte das suítes que nunca passaram por esse tipo de verificação, a resposta costuma surpreender bem menos positivamente do que o número de cobertura fazia parecer.