MÓDULO 02 · CONCEITO 06 DE 8

Mutation testing — testando os testes

Cobertura mente. Mutation testing introduz bugs propositais e verifica se os testes os pegam.

Tempo de leitura ~21 min Pré-requisito Property-based testing Próximo Contract testing

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:

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 + 0x = 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:

Estratégias práticas de adoção:

Ferramentas por linguagem

C# — Stryker.NET
# 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.

Python — mutmut
# 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.

Go — go-mutesting
# 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:

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.

heurística do sênior

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:

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

  1. 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.
  2. 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.
  3. 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

  1. livro Software Testing: A Craftsman's Approach (5th ed.) — Paul C. Jorgensen (2021). Trata mutation testing num contexto acadêmico amplo. Capítulo dedicado às bases formais.
  2. livro The Pragmatic Programmer (20th anniversary ed.) — Hunt & Thomas (2019). Não cobre mutation extensivamente, mas sustenta o argumento "teste seus testes" que motiva.
  3. artigo An Analysis of the Coupling Effect — Offutt (1992). Paper clássico que estabelece a base teórica: detectar mutantes simples implica detectar bugs reais. Evidência empírica forte.
  4. artigo Mutation Testing Repositories — Henry Coles (PIT). pitest.org/quickstart/basic_concepts/ — concepção autorial. PIT é a ferramenta Java; conceitos universais.
  5. artigo Why Code Coverage Lies — Henry Coles. stronglytypedblog.blogspot.com — argumentação clara contra cobertura como métrica única. Releitura obrigatória.
  6. artigo State of Mutation Testing at Google — Petrović & Ivanković. research.google/pubs/pub46584/ — paper de 2018 sobre como Google adapta mutation para escala. Aprenda dos obstáculos.
  7. docs Stryker Mutator. stryker-mutator.io — site oficial cobrindo Stryker.NET, Stryker para JS, e variantes. Documentação excelente.
  8. docs mutmut Documentation. mutmut.readthedocs.io — fonte primária para Python. Configuração e troubleshooting.
  9. docs go-mutesting. github.com/avito-tech/go-mutesting — fork mantido. README com exemplos práticos e flags de configuração.
  10. paper An Industrial Application of Mutation Testing — Petrović et al. (Google). ICSE 2018. Estudo empírico em escala. Como mutation testing pode coexistir com código de produção sem queimar CI.
  11. paper Are Mutants a Valid Substitute for Real Faults in Software Testing? — Just et al. FSE 2014. Validação empírica: mutantes correlacionam com bugs reais. Justifica o método.
  12. vídeo Mutation Testing — Henry Coles. YouTube. Coles é autor do PIT. Várias palestras sobre mutation testing prático. Foco em ROI.