Impor uma única pontuação de mutação em toda a sua base de código é uma ótima maneira de fazer sua equipe odiar testes.
Execute o PIT ou o Stryker em um repository típico e você verá o mesmo padrão: modules de autenticação marcam 40%, utilitários de string atingem 95%, e sua camada ORM fica em algum lugar dos 60. A reação automática é definir um gate global em, digamos, 70% e bloquear todo PR que caia abaixo disso. Duas sprints depois, alguém desativa a verificação no CI e culpa os “mutadores instáveis”.
O problema real não são as ferramentas. É fingir que todo código tem o mesmo raio de impacto quando um mutante sobrevive.
O que o teste de mutação realmente mede
A cobertura de código diz quais linhas foram executadas. O teste de mutação diz se seus testes notariam se essas linhas mudassem.
Um framework de mutação introduz pequenas falhas (mutantes) no seu código-fonte. Ele pode inverter um > para <, remover uma chamada de método, ou alterar um valor de retorno. Se sua test suite captura a mudança, o mutante é morto. Se os testes passam mesmo assim, o mutante sobrevive. Sua pontuação de mutação é a porcentagem de mutantes mortos.
Um mutante sobrevivente em uma comparação de hash de senha é um bug de segurança esperando para ir para produção. Um mutante sobrevivente em um helper capitalizeFirstLetter é, no pior dos casos, um rótulo de UI levemente estranho.
Tratá-los da mesma forma é onde as equipes erram.
Por que auth merece um gate de 90%+
O código de autenticação e autorização tem duas propriedades que o tornam ideal para teste de mutação agressivo.
Primeiro, a lógica costuma ser discreta e do tipo state machine. O token expirou? A role está no conjunto permitido? A assinatura foi verificada? Cada branch tem uma implicação de segurança clara, e cada uma deve ser testada.
Segundo, o custo de um mutante sobrevivente é catastrófico. Um único booleano invertido em uma verificação de role pode expor endpoints de admin. Um not omitido em uma rotina de validação de token pode aceitar JWTs forjados. Isso não é teórico. Bancos de dados de CVE estão cheios de bypasses de auth causados por erros de lógica que o teste de mutação teria capturado.
Na Sentry, impomos uma pontuação de mutação de 90% em qualquer coisa nos modules authn/ e authz/. Qualquer coisa abaixo disso falha no CI. Sem overrides, sem “vamos corrigir na próxima sprint”. O module é pequeno o suficiente para que isso seja alcançável sem escrever 40 linhas de teste para cada linha de código de produção.
É assim que isso funciona na prática. Esta é uma rotina simplificada de validação de JWT:
import time
from typing import Optional
def verify_token(token: dict, expected_aud: str, leeway: int = 30) -> bool:
now = time.time()
if token.get("aud") != expected_aud:
return False
exp = token.get("exp")
if exp is not None and now > exp + leeway:
return False
return True
Um framework de mutação pode inverter > para >= na verificação de expiração. Sem um teste que use um token expirando exatamente em now + leeway, aquele mutante sobrevive. Isso significa que seus testes não verificam de fato a fronteira. Com 90% de cobertura de mutação, esse teste existe.
Código utilitário pode viver com 60%
Seus StringUtils, DateHelpers e MathExtensions são o extremo oposto do espectro.
Esses modules tendem a ser puros, fortemente reutilizados e fáceis de raciocinar sobre. Um mutante sobrevivente em truncate(str, maxLen) que muda > para >= pode cortar um caractere a mais. Isso é uma peculiaridade de UI, não um incident de segurança.
A matemática de risco-retorno muda. Esses modules costumam ter dezenas de pequenas funções. Buscar 90% de cobertura de mutação significa escrever testes para cada variante off-by-one em padLeft. Os testes ficam maiores que o código que protegem, e o fardo de manutenção começa a superar o valor.
Definimos um piso de 60% para modules utilitários. Isso captura as lacunas óbvias (null checks faltantes, valores de retorno errados) sem forçar a equipe a testar exaustivamente cada permutação de slicing de string.
A chave é ser honesto sobre o que 60% significa. Significa “testamos os casos comuns e as falhas óbvias”. Não significa “esse código não importa”. Se uma função utilitária é usada em um caminho sensível à segurança, ela herda o limite mais alto do seu consumer.
O meio-termo: lógica de negócio
A maior parte do seu código fica entre esses polos. Processamento de pagamentos, validação de dados, orchestration de workflows. Esses modules afetam a correção e a confiança do usuário, mas um único mutante sobrevivente normalmente não entregará seu banco de dados a um atacante.
Usamos um sistema em camadas:
| Tipo de module | Limite de mutação | Justificativa |
|---|---|---|
| AuthN / AuthZ | 90% | Alto raio de impacto, lógica discreta |
| Lógica de negócio | 75% | Crítico para correção, complexidade moderada |
| Utilitários / helpers | 60% | Baixo raio de impacto, alta reutilização, funções simples |
| Gerado / boilerplate | Excluído | Não teste código que você não escreveu |
Isso não é uma regra rígida. Um module de cálculo de pagamentos pode subir para 85%. Um helper JSON amplamente utilizado pode ser promovido a 75% se for consumido por código de auth. As camadas são um ponto de partida, não uma gaiola.
Como implementar gates de mutação em camadas
Tanto o Stryker quanto o PIT suportam configuração por module. É assim que integramos isso em um projeto Python usando mutmut com uma configuração customizada:
# mutation_config.py
THRESHOLDS = {
"src/authn/": 90,
"src/authz/": 90,
"src/billing/": 85,
"src/workflows/": 75,
"src/utils/": 60,
}
EXCLUDE_PATHS = [
"src/generated/",
"src/migrations/",
]
No CI, um pequeno script lê essa configuração e executa o testador de mutação por module:
#!/usr/bin/env bash
# ci/check-mutation.sh
set -e
python -m mutmut run --paths-to-mutate=src/authn/
python -m mutmut results || true
python -m mutmut run --paths-to-mutate=src/utils/
python -m mutmut results || true
python ci/verify_thresholds.py
O script de verificação compara a pontuação de cada module com seu limite. Se src/authn/ marcar 87%, o build falha com uma mensagem clara: authn/ scored 87%, threshold is 90%.
Para o Stryker (JavaScript/TypeScript), use stryker.conf.js com grupos de mutadores:
// stryker.conf.js
module.exports = {
thresholds: {
high: 90,
low: 75,
break: null, // we handle this per-module
},
mutate: [
"src/auth/**/*.ts",
"src/billing/**/*.ts",
"src/utils/**/*.ts",
],
ignorePatterns: ["src/generated/**"],
};
Envolvemos o Stryker em um script que o executa três vezes com diferentes globs de caminho e impõe o limite por diretório após cada execução. É um pouco desajeitado, mas funciona.
A armadilha de perseguir 100%
Algumas equipes veem o teste de mutação como um jogo para vencer. Elas escrevem testes que existem apenas para matar mutantes, não para verificar comportamento.
O pior exemplo é testar se uma mensagem de exceção específica contém uma substring, apenas para que um mutante que muda o texto da mensagem seja morto. Esse teste não agrega valor. Ele não verifica se a exceção é lançada no momento certo, ou se o tipo correto é levantado. Ele só verifica a string.
Se você se pegar escrevendo testes puramente para empurrar uma porcentagem, você inverteu o objetivo. O teste de mutação é uma ferramenta de diagnóstico, não um ranking. A pontuação diz onde olhar. Ela não diz quando você terminou.
O que aprendemos da maneira mais difícil
Começamos com um gate global de 80%. Em um mês, três equipes o haviam desativado em branches de feature “temporariamente”. Duas dessas desativações temporárias se tornaram permanentes.
O problema não era o número. Era que 80% era baixo demais para código de auth (perdemos um bug de verificação de role que chegou ao staging) e alto demais para um module utilitário de 4.000 linhas (a equipe passou duas semanas escrevendo testes para variantes de isValidEmail).
Depois que dividimos em camadas, a adoção persistiu. As equipes de auth aceitaram a barra de 90% porque o escopo era delimitado. As equipes de plataforma aceitaram 60% para utilitários porque era alcançável sem loucura. A abordagem em camadas transformou o teste de mutação de uma punição em uma conversa sobre risco.
Por onde começar
Se você está introduzindo teste de mutação em uma base de código existente, não defina nenhum gate na primeira semana. Execute a ferramenta, olhe as pontuações e pergunte: onde um mutante sobrevivente doeria mais?
Comece com auth. Defina 90% lá, deixe tudo verde e prove o valor. Expanda para lógica de negócio assim que a equipe confiar no sinal. Mantenha utilitários com uma barra mais baixa ou exclua-os completamente até que você tenha construído o hábito.
E lembre-se: uma pontuação de 60% com testes honestos supera uma pontuação de 95% com testes escritos para enganar o mutador. O objetivo é capturar bugs reais, não impressionar seu dashboard de metrics.
Se você quiser tentar isso você mesmo, mutmut para Python e Stryker para JavaScript ambos suportam os padrões por diretório descritos acima. Comece pequeno. Um module de auth. Uma semana. Veja o que sobrevive.