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.