Testeando tus tests
Las métricas de code coverage te dicen qué código ejecutan tus tests, pero no si tus tests realmente atraparían bugs en ese código. Un test que ejecuta una línea pero nunca verifica el resultado logra cobertura sin proveer valor.
El mutation testing invierte la perspectiva: en lugar de medir cuánto código cubren tus tests, mide qué tan bien detectan fallas. Lo hace introduciendo deliberadamente bugs (mutaciones) en tu código fuente y verificando si tu test suite los atrapa.
Si tus tests pasan cuando se introduce un bug, esos tests son débiles.
Cómo funciona el mutation testing
El proceso sigue estos pasos:
Generar mutantes. La herramienta crea copias del código fuente, cada una con un cambio pequeño (mutación). Cada copia modificada es un mutante.
Ejecutar tests contra cada mutante. El test suite completo se ejecuta contra cada mutante.
Clasificar resultados:
- Mutante eliminado (killed) — al menos un test falla (bueno — tus tests atraparon la falla)
- Mutante sobreviviente (survived) — todos los tests pasan (malo — tus tests no detectaron la falla)
- Mutante equivalente — la mutación no cambia el comportamiento del programa (neutral)
Calcular mutation score. Mutation score = eliminados / (total - equivalentes) * 100%
Operadores de mutación comunes
Reemplazo de operadores aritméticos
# Original
total = price * quantity
# Mutante
total = price + quantity
Reemplazo de operadores relacionales
# Original
if age >= 18:
# Mutante
if age > 18:
Reemplazo de operadores lógicos
# Original
if is_active and is_verified:
# Mutante
if is_active or is_verified:
Reemplazo de constantes
# Original
MAX_RETRIES = 3
# Mutante
MAX_RETRIES = 0
Eliminación de sentencias
# Original
def process(data):
validate(data) # Esta línea eliminada en mutante
transform(data)
save(data)
Mutación de valores de retorno
# Original
return True
# Mutante
return False
Negación de condicionales
# Original
if not is_empty:
# Mutante
if is_empty:
El efecto acoplamiento y la hipótesis del programador competente
El mutation testing se basa en dos fundamentos teóricos:
Hipótesis del programador competente: Los programadores producen código que está cerca de ser correcto. Los bugs reales son típicamente errores pequeños — un operador incorrecto, un error off-by-one, una negación faltante. Los operadores de mutación simulan exactamente estos tipos de errores.
Efecto de acoplamiento: Los tests que detectan fallas simples (mutantes de primer orden) también detectarán fallas más complejas (mutantes de orden superior). Esto significa que testear con mutaciones simples es suficiente para evaluar la calidad de los tests.
Interpretación del mutation score
| Mutation Score | Evaluación |
|---|---|
| 90-100% | Excelente — test suite atrapa casi todas las fallas |
| 75-89% | Bueno — algunas debilidades a abordar |
| 60-74% | Regular — brechas significativas de testing |
| Menos de 60% | Pobre — tests proveen falsa confianza |
Herramientas de mutation testing
PIT (PITest) — Java
PIT es la herramienta más popular para Java.
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
</plugin>
Stryker — JavaScript/TypeScript
npm install --save-dev @stryker-mutator/core
npx stryker init
npx stryker run
Otras herramientas
- mutmut — Mutation testing para Python
- Infection — Mutation testing para PHP
- cosmic-ray — Otro mutation tester para Python
- cargo-mutants — Mutation testing para Rust
Consideraciones de rendimiento
El mutation testing es computacionalmente costoso. Estrategias para manejarlo:
Mutation testing incremental. Solo muta archivos modificados, no todo el codebase.
Selección de tests. Ejecuta solo los tests relevantes al código mutado.
Ejecución paralela. Ejecuta test suites de mutantes en paralelo.
Muestreo. Testea un subconjunto aleatorio de mutantes.
Prioriza código crítico. Ejecuta mutation testing en módulos críticos del negocio.
Ejercicio: Analizando resultados de mutación
Problema 1
Dada esta función y sus tests:
def calculate_discount(price, customer_type):
if customer_type == "premium":
return price * 0.8
elif customer_type == "regular":
return price * 0.9
else:
return price
def test_premium_discount():
assert calculate_discount(100, "premium") == 80
def test_regular_discount():
assert calculate_discount(100, "regular") == 90
def test_no_discount():
assert calculate_discount(100, "guest") == 100
Para cada mutante, predice si será eliminado o sobrevivirá:
- Cambiar
price * 0.8aprice * 0.9 - Cambiar
price * 0.9aprice * 0.8 - Cambiar
customer_type == "premium"acustomer_type != "premium" - Cambiar
return priceareturn 0 - Cambiar
price * 0.8aprice + 0.8
Solución
- Eliminado.
test_premium_discountespera 80 pero obtiene 90. - Eliminado.
test_regular_discountespera 90 pero obtiene 80. - Eliminado.
test_premium_discountentra al branch incorrecto. - Eliminado.
test_no_discountespera 100 pero obtiene 0. - Eliminado.
test_premium_discountespera 80 pero obtiene 100.8.
Todos los mutantes eliminados — mutation score: 100%.
Problema 2
Ahora considera una función con tests más débiles:
def is_eligible(age, income, has_account):
if age >= 18 and income > 30000:
if has_account:
return "APPROVED"
else:
return "PENDING"
return "REJECTED"
def test_approved():
result = is_eligible(25, 50000, True)
assert result == "APPROVED"
def test_rejected():
result = is_eligible(16, 20000, False)
assert result == "REJECTED"
- Cambiar
age >= 18aage > 18 - Cambiar
income > 30000aincome >= 30000 - Cambiar
has_accountanot has_account - Cambiar
return "PENDING"areturn "APPROVED" - Cambiar
andaoren la primera condición
Solución
- Sobrevive. El test usa age=25, que satisface tanto
>= 18como> 18. - Sobrevive. El test usa income=50000, que satisface tanto
> 30000como>= 30000. - Eliminado.
test_approvedahora entra al branchelsey retorna “PENDING”. - Sobrevive. Ningún test alcanza
return "PENDING". - Sobrevive. Con
or, los tests existentes producen los mismos resultados.
Mutation score: 1/5 = 20%. Para mejorar: agregar tests de boundary y cubrir el path “PENDING”.
Mutantes equivalentes: el desafío
Un mutante equivalente produce la misma salida que el original para todas las entradas posibles. Detectar mutantes equivalentes es indecidible en el caso general (equivalente al problema de la parada). Las herramientas modernas usan heurísticas para identificarlos.
Integrando mutation testing en CI/CD
- Empieza con módulos críticos. No ejecutes mutation testing en todo el codebase inicialmente.
- Establece un threshold. Falla el build si el mutation score baja de un objetivo (ej: 80%).
- Ejecuta incrementalmente. Solo muta código cambiado en el PR actual.
- Úsalo para code review. Comparte reportes de mutación con revisores.
Puntos clave
- El mutation testing evalúa la calidad de los tests introduciendo fallas deliberadas en el código fuente
- Mutantes eliminados = buenos tests; mutantes sobrevivientes = brechas de testing
- Mutation score = eliminados / (total - equivalentes) * 100%
- Operadores comunes: reemplazo aritmético, relacional, lógico; eliminación de sentencias; mutación de retorno
- Herramientas: PIT (Java), Stryker (JS/TS), mutmut (Python), Infection (PHP)
- El mutation testing es costoso — usa estrategias incrementales, paralelas y selectivas
- Los mutantes equivalentes no pueden eliminarse y deben excluirse del scoring
- Apunta a 80%+ de mutation score en lógica de negocio crítica