Exigir un único porcentaje de mutación en toda tu base de código es una forma excelente de hacer que tu equipo odie las pruebas.
Ejecuta PIT o Stryker contra un repository típico y verás el mismo patrón: los modules de autenticación marcan un 40%, las utilidades de string alcanzan el 95%, y tu capa ORM se queda en algún punto de los 60. La respuesta instintiva es establecer una barrera global en, digamos, el 70% y bloquear cada PR que caiga por debajo. Dos sprints después, alguien desactiva la verificación en el CI y le echa la culpa a los “mutators inestables”.
El problema real no son las herramientas. Es pretender que todo el código tiene el mismo radio de impacto cuando un mutante sobrevive.
Qué mide realmente el mutation testing
El code coverage te dice qué líneas se ejecutaron. El mutation testing te dice si tus pruebas se darían cuenta si esas líneas cambiaran.
Un framework de mutation testing introduce pequeños fallos (mutantes) en tu código fuente. Puede invertir un > por un <, eliminar una llamada a método, o cambiar un valor de retorno. Si tu suite de pruebas detecta el cambio, el mutante es asesinado. Si de todos modos pasa, el mutante sobrevive. Tu mutation score es el porcentaje de mutantes asesinados.
Un mutante sobreviviente en una comparación de hash de contraseña es un bug de seguridad esperando llegar a producción. Un mutante sobreviviente en un helper capitalizeFirstLetter es, en el peor de los casos, una tag de UI ligeramente rara.
Tratarlos de la misma manera es donde los equipos se equivocan.
Por qué auth merece una barrera del 90% o más
El código de autenticación y autorización tiene dos propiedades que lo hacen ideal para un mutation testing agresivo.
Primero, la lógica suele ser discreta y similar a una state machine. ¿Expiró el token? ¿Está el rol en el conjunto permitido? ¿Se verificó la firma? Cada branch tiene una implicación de seguridad clara, y cada una debería ser probada.
Segundo, el costo de un mutante sobreviviente es catastrófico. Un único booleano invertido en una verificación de rol puede exponer endpoints de administrador. Un not omitido en una rutina de validación de tokens puede aceptar JWTs falsificados. Esto no es teórico. Las bases de datos de CVE están llenas de bypasses de autenticación causados por errores de lógica que el mutation testing habría detectado.
En Sentry, exigimos un mutation score del 90% en todo lo que esté en los modules authn/ y authz/. Cualquier valor por debajo de eso falla en el CI. Sin excepciones, sin “lo arreglaremos en el próximo sprint”. El module es lo suficientemente pequeño como para que esto sea alcanzable sin escribir 40 líneas de prueba por cada línea de código de producción.
Así es como se ve en la práctica. Esta es una rutina de validación JWT simplificada:
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
Un framework de mutation testing podría invertir > por >= en la verificación de expiración. Sin una prueba que use un token que expire exactamente en now + leeway, ese mutante sobrevive. Eso significa que tus pruebas en realidad no verifican el límite. Con un 90% de cobertura de mutación, esa prueba existe.
El código de utilidades puede vivir con un 60%
Tus StringUtils, DateHelpers y MathExtensions están en el extremo opuesto del espectro.
Estos modules tienden a ser puros, muy reutilizados y fáciles de razonar. Un mutante sobreviviente en truncate(str, maxLen) que cambie > por >= podría recortar un carácter extra. Eso es una peculiaridad de la UI, no un incident de seguridad.
La ecuación riesgo-recompensa cambia. Estos modules a menudo tienen docenas de funciones pequeñas. Perseguir un 90% de cobertura de mutación significa escribir pruebas para cada variante off-by-one en padLeft. Las pruebas se vuelven más largas que el código que protegen, y la carga de mantenimiento empieza a superar el valor.
Establecemos un piso del 60% para los modules de utilidades. Eso detecta los vacíos obvios (faltan null checks, valores de retorno incorrectos) sin obligar al equipo a probar exhaustivamente cada permutación de corte de string.
La clave es ser honesto sobre lo que significa el 60%. Significa “hemos probado los casos comunes y los fallos obvios”. No significa “este código no importa”. Si una función de utilidades se usa en un camino sensible a la seguridad, hereda el umbral más alto de su consumer.
El término medio: la lógica de negocio
La mayor parte de tu código se sitúa entre estos dos polos. Procesamiento de pagos, validación de datos, orchestration de flujos de trabajo. Estos modules afectan la corrección y la confianza del usuario, pero un único mutante sobreviviente normalmente no le entregará tu base de datos a un atacante.
Usamos un sistema por niveles:
| Tipo de module | Umbral de mutación | Justificación |
|---|---|---|
| AuthN / AuthZ | 90% | Alto radio de impacto, lógica discreta |
| Lógica de negocio | 75% | Crítica para la corrección, complejidad moderada |
| Utilidades / helpers | 60% | Bajo radio de impacto, alta reutilización, funciones simples |
| Generado / boilerplate | Excluido | No pruebes código que no escribiste |
Esto no es una regla rígida. Un module de cálculo de pagos podría subir al 85%. Un helper JSON ampliamente usado podría ascender al 75% si es consumido por código de auth. Los niveles son un punto de partida, no una jaula.
Cómo implementar barreras de mutación por niveles
Stryker y PIT ambos soportan configuración por module. Así es como lo integramos en un proyecto de Python usando mutmut con una configuración personalizada:
# 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/",
]
En el CI, un pequeño script lee esta configuración y ejecuta el mutation tester 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
El script de verificación comprueba el puntaje de cada module contra su umbral. Si src/authn/ marca un 87%, el build falla con un mensaje claro: authn/ scored 87%, threshold is 90%.
Para Stryker (JavaScript/TypeScript), usa stryker.conf.js con 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 Stryker en un script que lo ejecuta tres veces con diferentes globs de rutas y aplica el umbral por directorio después de cada ejecución. Es un poco tosco, pero funciona.
La trampa de perseguir el 100%
Algunos equipos ven el mutation testing como un juego que hay que ganar. Escriben pruebas que existen solo para asesinar mutantes, no para verificar comportamiento.
El peor ejemplo es probar que un mensaje de excepción específico contiene una subcadena, solo para que un mutante que cambia el texto del mensaje sea asesinado. Esa prueba no añade valor. No verifica que la excepción se lance en el momento correcto, o que se lance el tipo correcto. Solo verifica el string.
Si te descubres escribiendo pruebas puramente para empujar un porcentaje, has invertido el objetivo. El mutation testing es una herramienta de diagnóstico, no una tabla de clasificación. El puntaje te dice dónde mirar. No te dice cuándo has terminado.
Lo que aprendimos a la mala
Empezamos con una barrera global del 80%. En menos de un mes, tres equipos la habían desactivado en branches de feature “temporalmente”. Dos de esas desactivaciones temporales se volvieron permanentes.
El problema no era el número. Era que el 80% era demasiado bajo para código de auth (nos perdimos un bug de verificación de rol que llegó a staging) y demasiado alto para un module de utilidades de 4,000 líneas (el equipo pasó dos semanas escribiendo pruebas para variantes de isValidEmail).
Después de que dividimos en niveles, la adopción perduró. Los equipos de auth aceptaron la barrera del 90% porque el alcance estaba delimitado. Los equipos de plataforma aceptaron el 60% para utilidades porque era alcanzable sin locura. El enfoque por niveles convirtió el mutation testing de un castigo en una conversación sobre riesgo.
Por dónde empezar
Si estás introduciendo mutation testing en una base de código existente, no establezcas ninguna barrera en la primera semana. Ejecuta la herramienta, mira los puntajes y pregúntate: ¿dónde un mutante sobreviviente causaría más daño?
Empieza con auth. Pon el 90% ahí, hazlo pasar, y demuestra el valor. Expándete a la lógica de negocio una vez que el equipo confíe en la señal. Mantén las utilidades con una barrera más baja o exclúyelas por completo hasta que hayas construido el hábito.
Y recuerda: un puntaje del 60% con pruebas honestas vence a un puntaje del 95% con pruebas escritas para engañar al mutador. El objetivo es atrapar bugs reales, no impresionar tu dashboard de metrics.
Si quieres probar esto tú mismo, mutmut para Python y Stryker para JavaScript ambos soportan los patrones por directorio descritos arriba. Empieza pequeño. Un module de auth. Una semana. Mira qué sobrevive.