A métrica mais usada para "quão bons são os testes?" é cobertura — o percentual de linhas (ou branches) que os testes executam. Cobertura é útil; cobertura também mente, e mente especificamente nos casos onde você mais precisa de honestidade. Você pode ter 95% de cobertura e testes que não verificam nada. O código roda nos testes; isso não significa que algum assert vai pegar bug se ele aparecer ali. Mutation testing é a técnica que ataca exatamente esse buraco — respondendo, com método matemático, à pergunta: seus testes pegam mudanças no código que deveriam pegar?
A ideia é elegante e estranhamente subversiva: a ferramenta pega o seu
código, faz pequenas modificações deliberadas (mutações: troca um
+ por -, um < por
<=, remove uma linha), roda a sua suíte, e verifica se
pelo menos um teste falha. Se algum teste falha, o "mutante foi morto"
— bom, sua suíte detectou o bug introduzido. Se todos os testes passam
mesmo com o código mutado, o "mutante sobreviveu" — sua suíte tem um
buraco. Repetindo isso para centenas de mutações, você obtém um
diagnóstico preciso: qual percentual de bugs introduzíveis sua
suíte realmente pega?
Por que cobertura mente
Considere este código e seu teste:
def is_eligible(age, has_id):
if age >= 18 and has_id:
return True
return False
# Teste
def test_is_eligible():
assert is_eligible(20, True) == True
assert is_eligible(15, True) == False
Cobertura: 100%. Cada linha do is_eligible é executada por
ao menos um dos dois testes. Métrica feliz. Mas note: se você mudar
age >= 18 para age > 18 (off-by-one
clássico), os dois testes continuam passando — 20 ainda é maior que
18, 15 ainda é menor. O bug foi introduzido e ninguém percebeu. Sua
cobertura de 100% mostrou nada.
Esse é o tipo de buraco que mutation testing detecta e que cobertura
tradicional silenciosamente esconde. A ferramenta tentaria a mutação
>= → >, veria que nenhum teste falha,
e te avisaria: "mutante sobreviveu na linha 2; sua suíte não distingue
entre >= e >". Você adiciona o teste
faltante (is_eligible(18, True) == True), reroda, mutante
morto.
Os tipos de mutação
Ferramentas modernas aplicam dezenas de tipos de mutação. Os mais comuns:
-
Operadores aritméticos:
+↔-,*↔/. -
Operadores de comparação:
<↔<=,==↔!=,>↔>=. -
Operadores lógicos:
&&↔||, negação!cond. -
Constantes:
0→1,true→false,"x"→"". -
Boundary changes:
i < n→i <= n(off-by-one). - Statement deletion: remove uma linha inteira.
-
Return value: substitui retorno por valor padrão
(
null,0,""). - Conditional negation: inverte branches.
Cada um corresponde a uma classe de bug real que aparece em código de produção. Os bugs mais comuns em sistemas reais — off-by-one, negações esquecidas, condições trocadas — são exatamente o que essas mutações simulam. Por isso o mutation score (% de mutantes mortos) é uma proxy muito melhor de qualidade de testes do que cobertura.
O que é um "bom" mutation score
Diferente de cobertura, mutation score raramente atinge 100%. Há
mutantes equivalentes — mudanças sintáticas que não alteram
comportamento (ex: x = a + 0 → x = a + 1
em código onde a é sempre 0). Ferramentas tentam
detectar e excluir, mas falham em casos sutis. Em projetos reais,
80-90% de mutation score já é excelente. Acima de 90% é raro e
caro de manter.
Mais importante que o número absoluto é o delta: você adiciona código novo e o mutation score do módulo cai. Esse é sinal claro de que os testes do código novo são frouxos. Métrica como "novo código deve manter ou melhorar mutation score" é mais útil que "objetivo de 85%".
O custo — e como manejá-lo
Mutation testing é caro. Para cada mutação, a ferramenta roda a suíte de testes inteira. Se você tem 1000 mutações geradas e suíte que roda em 5 segundos, são ~83 minutos de CPU. Para projetos com suítes maiores, pode virar horas.
Otimizações que ferramentas modernas implementam:
- Test selection inteligente: para cada mutação na linha X, só rodar testes que tocam a linha X. Reduz drasticamente o tempo.
- Mutações incrementais: testar só código mudado no PR, não codebase inteiro. Faz mutation viável em CI por PR.
- Paralelismo: distribuir mutações em workers.
- Parar no primeiro kill: assim que um teste falha para a mutação, não rodar os outros.
- Filtros configuráveis: excluir mutações em código gerado, configuração, logs.
Estratégias práticas de adoção:
- Não rodar tudo no Tier 1. Mutation testing pertence ao Tier 3 (pré-deploy) ou Tier 4 (noturno). Não bloquear ciclo TDD com isso.
- Mutation diff em PRs: rode mutation só no que mudou. Stryker.NET, mutmut e outros suportam isso.
- Foco em módulos críticos: domínio de negócio, cálculos, validações. Não em adapters CRUD que são casca fina.
- Excluir o que não vale: getters/setters, logs, código de boilerplate. Cada exclusão é decisão consciente.
Ferramentas por linguagem
# Instalação
dotnet tool install -g dotnet-stryker
# Rodar
cd src/MyProject.Tests
dotnet stryker
# Configuração — stryker-config.json
{
"stryker-config": {
"project": "MyProject.csproj",
"test-projects": ["MyProject.Tests.csproj"],
"mutate": ["**/*.cs", "!**/Migrations/**"],
"thresholds": {
"high": 80,
"low": 60,
"break": 50
},
"reporters": ["html", "json", "progress"]
}
}
Stryker.NET é a ferramenta padrão. Gera HTML report visual mostrando
mutações sobreviventes inline com código. Suporta diff mode com
--since.
# Instalação
pip install mutmut
# Configuração — setup.cfg ou pyproject.toml
[mutmut]
paths_to_mutate=src/
runner=pytest -x
tests_dir=tests/
# Rodar (demorado!)
mutmut run
# Ver resultados
mutmut show
mutmut show 7 # detalhes do mutante 7
mutmut html # gera report HTML
mutmut é o padrão Python. Cosmic-ray é alternativa mais rica mas mais complexa. Ambos são lentos — em CI, restrinja a paths específicos e rode em pipeline noturno.
# Instalação
go install github.com/avito-tech/go-mutesting/cmd/go-mutesting@latest
# Rodar em um package
go-mutesting ./internal/domain/...
# Configuração — disable mutações específicas
go-mutesting \
--disable=branch/case \
--disable=expression/remove \
./internal/domain/...
go-mutesting (fork mantido pela Avito Tech) é a opção mais ativa em Go. Ecossistema é menos maduro que .NET ou Python — espere mais configuração manual.
O que mutation testing revela
Quando você roda mutation pela primeira vez, vai descobrir três categorias de problemas:
Mutantes em código sem assertion
Você tem teste que chama a função mas não verifica saída (ou só verifica que não lança exceção). Mutações em qualquer lugar sobrevivem porque nada compara resultado. Sintoma claro de teste sem valor.
# Teste fraco
def test_calculate_total():
order = Order(items=[item1, item2])
order.calculate_total() # nenhum assert!
# Mutar calculate_total para retornar 0 — teste passa.
Mutantes em condições não cobertas
Existem dois caminhos no código (if/else); só um tem teste. Mutações no caminho não-coberto sobrevivem trivialmente. Cobertura tradicional pegaria isso, mas mutation localiza com precisão.
Mutantes em boundary cases
O caso mais comum e mais valioso. Você tem testes que cobrem
"age=15" e "age=20", mas a fronteira em
"age=18" não é testada. Mutation testing
>= → > sobrevive. Adicione
age=18; mutante morre.
Esses três padrões aparecem em quase toda primeira execução de mutation. Corrigi-los já melhora a suíte significativamente.
Mutation vs property-based — complementares
Há sobreposição entre mutation e property-based, mas eles atacam ângulos diferentes:
- Mutation testa os testes: dado um conjunto de testes, eles distinguem código correto de código quase-correto?
- Property-based testa o código: dado código, ele satisfaz invariantes para qualquer input?
Um sistema bem-coberto tem ambos. Property-based encontra bugs em código que você não pensou em testar. Mutation encontra buracos em testes que você pensou em escrever. Ambos atacam o ponto cego — você não pode pensar em tudo.
Cuidadosamente: mutation testing aplicado a property-based testing é onde verdadeiramente faz diferença. Propriedades bem-escritas tendem a matar mutantes em larga escala (uma propriedade cobre milhares de inputs, então qualquer mutação significativa provavelmente quebra para algum input). Quando você combina os dois, mutation score sobe drasticamente — e onde mutantes ainda sobrevivem, você descobriu propriedades que faltam.
Antipatterns e armadilhas
Mutation score como meta absoluta
"Time, vamos atingir 95%". Resultado: gente escrevendo testes artificiais para matar mutantes específicos sem agregar valor. Testes começam a virar duplicação de implementação. Mutation score vira métrica gameable.
Trate mutation score como sinal de alerta, não como objetivo. Mutantes sobreviventes merecem investigação, não fix mecânico.
Mutações em código gerado / boilerplate
Código gerado (ORMs, mocks, DTOs) frequentemente recebe mutações que ninguém deveria estar testando. Resultado: mutation score baixo injustamente, com gente perdendo tempo em mutantes irrelevantes. Configure exclusões cedo.
Equivalent mutants frustrantes
i++ em loop pode ser mutado para i--; se
o loop sai por outra condição, comportamento idêntico. Esses
"mutantes equivalentes" são impossíveis de matar sem mudar o teste
para verificar exatamente o método de iteração — coisa que ninguém
quer testar.
Aceite que 100% mutation score não é meta realista. Acima de 80% em código de domínio é bom; acima de 90% é excelente.
Falsa sensação de segurança
Alto mutation score não significa código sem bug. Significa que os tipos de bugs que mutation simula são detectáveis. Bugs de design, de concorrência, de protocolos, de integração externa — mutation não pega. É uma camada, não a única.
Em projetos novos, espere até a suíte ter forma antes de ligar mutation testing. Aplicar em código fresco com poucos testes gera muito ruído. Comece em módulos maduros: domínio que já tem boa cobertura. O ROI aparece quando mutation revela buracos sutis numa suíte que parecia robusta.
Caso real — um exemplo de bug encontrado
Para ilustrar concretamente, considere uma função de validação de cupom de desconto:
def calculate_discount(price, coupon):
if coupon.is_valid and price >= coupon.min_purchase:
return price * (1 - coupon.percentage / 100)
return price
Suíte de testes (cobertura 100%):
def test_valid_coupon_applies():
coupon = Coupon(is_valid=True, min_purchase=100, percentage=10)
assert calculate_discount(150, coupon) == 135
def test_invalid_coupon_no_discount():
coupon = Coupon(is_valid=False, min_purchase=100, percentage=10)
assert calculate_discount(150, coupon) == 150
def test_below_minimum_no_discount():
coupon = Coupon(is_valid=True, min_purchase=100, percentage=10)
assert calculate_discount(50, coupon) == 50
Mutation testing roda. Mutações testadas e seus destinos:
and→or: sobrevive — para o caso de cupom inválido, ambos retornam corretamente; nenhum teste pega.>=→>: sobrevive — boundary 100 não é testado.1 -→1 +: morto pelo primeiro teste (135 vira 165)./ 100→/ 99: morto pelo primeiro teste.
Dois mutantes sobreviveram. Você adiciona dois testes:
def test_invalid_coupon_below_min_no_discount():
# Mata mutante 'and' -> 'or'
coupon = Coupon(is_valid=False, min_purchase=100, percentage=10)
assert calculate_discount(50, coupon) == 50
def test_exactly_minimum_applies_discount():
# Mata mutante '>=' -> '>'
coupon = Coupon(is_valid=True, min_purchase=100, percentage=10)
assert calculate_discount(100, coupon) == 90
Reroda mutation; ambos morrem. Cobertura permaneceu 100%, mas a qualidade da suíte subiu visivelmente — agora você pega bugs que antes passariam.
Como praticar
- Rode mutation em projeto seu. Configure Stryker/mutmut/go-mutesting. Veja o relatório. Investigue 5 mutantes sobreviventes. Para cada um, decida: bug no teste, equivalente, ou exclusão.
- Combine mutation + property-based. Pegue uma função onde você tem property tests e example tests. Rode mutation. Veja qual conjunto cobre mais — geralmente properties matam muito mais.
- Configure mutation incremental no CI. Em projeto com PR workflow, configure para rodar só em código novo (diff contra main). Veja como isso muda a conversa em PRs — passa de "cobertura caiu" para "mutation score não regrediu".
Referências para aprofundar
- livro Software Testing: A Craftsman's Approach (5th ed.) — Paul C. Jorgensen (2021).
- livro The Pragmatic Programmer (20th anniversary ed.) — Hunt & Thomas (2019).
- artigo An Analysis of the Coupling Effect — Offutt (1992).
- artigo Mutation Testing Repositories — Henry Coles (PIT).
- artigo Why Code Coverage Lies — Henry Coles.
- artigo State of Mutation Testing at Google — Petrović & Ivanković.
- docs Stryker Mutator.
- docs mutmut Documentation.
- docs go-mutesting.
- paper An Industrial Application of Mutation Testing — Petrović et al. (Google).
- paper Are Mutants a Valid Substitute for Real Faults in Software Testing? — Just et al.
- vídeo Mutation Testing — Henry Coles.