Mutation Testing: Mide la efectividad real de tus pruebas
3 de noviembre de 2025
La cobertura de código indica cuántas líneas ejecutan las pruebas, pero no si las aserciones protegen el comportamiento correcto. En muchos equipos, un alto porcentaje de cobertura convive con regresiones silenciosas: los tests “pasan”, pero no detectan fallos relevantes.
Mutation testing aborda ese vacío introduciendo cambios mínimos y controlados en el código y ejecutando de nuevo la suite. Si el test detecta el cambio, el mutante muere; si no, sobrevive y revela una vulnerabilidad en la prueba. Su objetivo no es cubrir líneas, sino medir la calidad real de las pruebas.
🎃 Qué es y cómo funciona
Las herramientas de mutation testing generan versiones alteradas del código mediante operadores de cambio (relacionales, lógicos, aritméticos, de flujo, etc.).
Por cada mutación, ejecutan la suite de pruebas y clasifican los resultados según su capacidad para detectar el cambio.
| Estado | Significado |
|---|---|
| 🎉 Killed | El test falla con la mutación → detectó el cambio. |
| 🙁 Survived | El test pasa con la mutación → hay un hueco de aserciones. |
| 🫥 Incompetent / Timeout | No evaluable por error, inestabilidad o tiempo excesivo. |
A partir de estos resultados se calcula el Mutation Score:
Mutation Score = (Mutantes muertos / Mutantes totales) × 100
21 total → 🎉 15 killed, 🫥 0 incompetents, ⏰ 2 timeouts, 🙁 4 survivors
Mutation Score = (15/21)x100 = 71,42%
Mutation Score refleja la proporción de mutantes que los tests lograron detectar: un indicador objetivo de protección real del código.
🎃 Integración CI/CD: quality gate automático
Este indicador (mutation score) puede actuar como quality gate tanto en pre-commit como en entornos de integración continua, bloqueando “merges” si el score no alcanza un umbral (por ejemplo, ≥ 85 %).
pre-commit (bash)
#!/usr/bin/env bash
**set** **\-euo** **pipefail**
\# Ejecuta tu runner de mutación (Stryker/Mutmut/etc.)
**npm** **run** **test****:mutation**
\# Lee el score del reporte (ajusta la ruta y el campo)
**MS=$(jq** **\-r** **'.mutationScore'** **reports/mutation.json)**
**TH=****85**
**echo** **"Mutation Score = ${MS}% (umbral = ${TH}%)"**
**awk** **\-v** **ms=****"$MS"** **\-v** **th=****"$TH"** **'BEGIN { exit (ms < th) ? 1 : 0 }'** **\\\\**
**||** **{** **echo** **"❌ MS por debajo del umbral"****;** **exit** **1****;** **}**
**echo** **"✅ MS cumple el umbral"**
GitHub Actions (CI/CD)
**jobs:**
**mutation-tests:**
**runs-on:** **ubuntu-latest**
**steps:**
**\-** **uses:** **actions/checkout@v4**
**\-** **uses:** **actions/setup-node@v4**
**with:** **{** **node-version:** **'20'** **}**
**\-** **run:** **npm** **ci**
**\-** **name:** **Run** **mutation** **tests**
**run:** **npm** **run** **test:mutation**
**\-** **name:** **Enforce** **Mutation** **Score** **≥** **85%**
**run:** **|**
**MS=$(jq** **\-r** **'.mutationScore'** **reports/mutation.json)**
**TH=85**
**echo** **"MS=$MS** **TH=$TH"**
**awk** **\-v** **ms="$MS"** **\-v** **th="$TH"** **'BEGIN** **{** **exit** **(ms** **<** **th)** **?** **1** **:** **0** **}'** **\\\\**
**||** **{** **echo** **"❌** **Mutation** **Score** **por** **debajo** **de** **$TH%";** **exit** **1;** **}**
🎃 Uso práctico de “Mutmut" (CLI desde la versión 3.0) Una vez configurado el entorno, Mutmut permite ejecutar pruebas de mutación desde la línea de comandos de forma sencilla.
A continuación, algunos comandos clave para el día a día, actualizados según la versión 3.x.
🔹 Inicialización y ejecución
**\-** **mutmut** **run**
\*⠦ Generating mutants
done in 33ms
⠋ Listing all tests
⠦ Running clean tests
done
⠙ Running forced fail test
done
Running mutation testing
⠸ 21/21 🎉 2 🫥 0 ⏰ 0 🤔 0 🙁 19 🔇 0
263.17 mutations/second\*
Ejecuta todas las mutaciones sobre el código y registra los resultados en caché.
Utiliza la configuración de Pytest por defecto o los parámetros definidos en pytest.ini o pyproject.toml.
💡 Consejo: añade --paths-to-mutate src/ para limitar el alcance a un módulo o paquete concreto.
🔹 Ver resultados resumidos
**\-** **mutmut** **results**
pricing.x\_apply\_discount\_\_mutmut\_1: survived
pricing.x\_apply\_discount\_\_mutmut\_2: survived
pricing.x\_apply\_discount\_\_mutmut\_3: survived
pricing.x\_apply\_discount\_\_mutmut\_4: survived
pricing.x\_apply\_discount\_\_mutmut\_5: survived
pricing.x\_apply\_discount\_\_mutmut\_6: survived
pricing.x\_apply\_discount\_\_mutmut\_7: survived
pricing.x\_apply\_discount\_\_mutmut\_8: survived
pricing.x\_apply\_discount\_\_mutmut\_9: survived
pricing.x\_apply\_discount\_\_mutmut\_10: survived
pricing.x\_apply\_discount\_\_mutmut\_11: survived
pricing.x\_apply\_discount\_\_mutmut\_13: survived
pricing.x\_apply\_discount\_\_mutmut\_14: survived
pricing.x\_apply\_discount\_\_mutmut\_15: survived
pricing.x\_apply\_discount\_\_mutmut\_16: survived
Muestra el estado de cada mutante y su resultado.
Permite identificar de un vistazo qué partes del código tienen survivors (mutaciones no detectadas por los tests).
🔹 Detalle de mutantes individuales
**\-** **mutmut** **show** \*pricing.x\_apply\_discount\_\_mutmut\_1\*
\*\*\*\*\*# pricing.x\_apply\_discount\_\_mutmut\_1: survived
\--- src/pricing.py
+++ src/pricing.py
@@ \-1,5 +1,5 @@
def apply\_discount(price: float, pct: float) \-> float:
\- if price <= 0:
+ if price < 0:
return 0.00
if pct < 0 or pct \> 100:
raise ValueError("pct out of range")\*
Muestra el diff exacto del mutante, lo que facilita entender qué aserción falta o qué caso de borde no está cubierto.
🔹 Reporte visual interactivo
**\-** **mutmut** **browse**
Abre una interfaz web local que permite navegar los mutantes, revisar los diffs y los estados (killed, survived, timeout, etc.) de manera visual.

🔹 Limpieza de caché y re ejecución incremental
**rm** **\-rf** **.mutmut-cache**
Elimina los resultados antiguos antes de una nueva ejecución.
Permite ejecutar una pasada limpia cuando ha cambiado la lógica o se han modificado las pruebas.
🔹 Ejemplo de flujo completo
**\-** **rm** **\-rf** **.mutmut-cache**
**\-** **PYTEST\_ADDOPTS="-m** **bad\_tests"** **mutmut** **run**
**\-** **mutmut** **results**
**\-** **mutmut** **browse**
\*⠦ Generating mutants
done in 31ms
⠋ Listing all tests
⠦ Running clean tests
done
⠙ Running forced fail test
done
Running mutation testing
⠸ 21/21 🎉 2 🫥 0 ⏰ 0 🤔 0 🙁 19 🔇 0
240.09 mutations/second
pricing.x\_apply\_discount\_\_mutmut\_1: survived
pricing.x\_apply\_discount\_\_mutmut\_2: survived
pricing.x\_apply\_discount\_\_mutmut\_3: survived
pricing.x\_apply\_discount\_\_mutmut\_4: survived
pricing.x\_apply\_discount\_\_mutmut\_5: survived
pricing.x\_apply\_discount\_\_mutmut\_6: survived
pricing.x\_apply\_discount\_\_mutmut\_7: survived
pricing.x\_apply\_discount\_\_mutmut\_8: survived
pricing.x\_apply\_discount\_\_mutmut\_9: survived
pricing.x\_apply\_discount\_\_mutmut\_10: survived
pricing.x\_apply\_discount\_\_mutmut\_11: survived
pricing.x\_apply\_discount\_\_mutmut\_13: survived
pricing.x\_apply\_discount\_\_mutmut\_14: survived
pricing.x\_apply\_discount\_\_mutmut\_15: survived
pricing.x\_apply\_discount\_\_mutmut\_16: survived
pricing.x\_apply\_discount\_\_mutmut\_17: survived
pricing.x\_apply\_discount\_\_mutmut\_18: survived
pricing.x\_apply\_discount\_\_mutmut\_20: survived
pricing.x\_apply\_discount\_\_mutmut\_21: survived\*
Este flujo ejecuta una pasada de mutación sobre tests marcados con la etiqueta bad_tests, muestra los resultados y abre el explorador visual para análisis rápido.

🎃 Qué valida realmente cada operador
🧮 Relacionales
(>, ≥, <, ≤, ==, !=)
Revelan si los tests fijan correctamente los bordes. Si cambiar > por >= no rompe nada, falta una aserción en el límite inclusivo.
⚙ Lógicos
(and, or, negaciones)
Detectan combinaciones incorrectas de reglas. Un and sustituido por or debería fallar si la lógica está bien comprobada.
➕ Aritméticos
(+, −, *, /)
Exigen valores exactos. Aserciones vagas como > 0 no detectan variaciones en cálculos.
🔁 Control de flujo
(Quitar return, invertir un if)
Obligan a comprobar rutas críticas y condiciones tempranas.
🔢 Constantes
(True/False, 1/0, factores fijos)
Verifican que los tests anclan los valores del dominio (topes, redondeos, umbrales).
Cada tipo de mutación pone en evidencia una intención de prueba ausente…” (ligeramente más editorial).
🎃 Ejemplo didáctico: detectar lo que la cobertura no ve
A continuación se muestra un ejemplo con la función apply_discount, donde se aprecia cómo el mutation testing distingue entre un test superficial y uno robusto.
Función objetivo
def apply\_discount(price: float, pct: float) \-> float:
if price <= 0:
return 0.00
if pct < 0 or pct \> 100:
raise ValueError("pct out of range")
return round(price \* (1 \- pct / 100), 2)
Tenemos un coverage del 100%

Tests insuficientes (parecen correctos, pero no protegen)
import pytest
from pricing import apply\_discount
@pytest.mark.unitTests
@pytest.mark.bad\_tests
def test\_zero\_price\_returns\_something():
assert apply\_discount(0, 10) is not None
@pytest.mark.unitTests
@pytest.mark.bad\_tests
def test\_invalid\_pct\_does\_not\_crash\_the\_suite():
try:
apply\_discount(100, 101) # pct \> 100
except Exception:
pass
@pytest.mark.unitTests
@pytest.mark.bad\_tests
def test\_nominal\_is\_positive():
assert apply\_discount(100, 10) \> 0

Qué pasaría: si un mutante cambia price <= 0 por price < 0, los tests “débiles” seguirían pasando porque no validan el corto-circuito con price == 0 combinado con pct inválido. No hay aserciones sobre errores esperados ni bordes intencionales; por eso el mutante sobrevive.
Tests mejorados (mata variaciones y cubre bordes)
import pytest
from pricing import apply\_discount
@pytest.mark.unitTests
@pytest.mark.good\_tests
def test\_price\_short\_circuit\_and\_negatives():
assert apply\_discount(0, 10) \== 0.00
assert apply\_discount(-5, 10) \== 0.00
assert apply\_discount(0, 101) \== 0.00
assert apply\_discount(0, \-1) \== 0.00
assert apply\_discount(1, 10) \== 0.90
@pytest.mark.unitTests
@pytest.mark.good\_tests
def test\_invalid\_pct\_with\_exact\_message():
with pytest.raises(ValueError) as excinfo:
apply\_discount(100, \-1)
assert str(excinfo.value) \== "pct out of range"
with pytest.raises(ValueError) as excinfo:
apply\_discount(100, 101)
assert str(excinfo.value) \== "pct out of range"
@pytest.mark.unitTests
@pytest.mark.good\_tests
def test\_pct\_edges\_and\_nominal\_rounding():
assert apply\_discount(100, 0) \== 100.00
assert apply\_discount(100, 100) \== 0.00
assert apply\_discount(99.99, 15) \== 84.99

Por qué es mejor:
-
Corto-circuito en precio: los casos price == 0 con pct inválido (1, 101) fuerzan a que la función no evalúe la validación de pct. Si el mutante cambia price <= 0 por price < 0, se lanzaría ValueError y el test fallaría → el mutante muere.
-
Bordes en porcentaje: los valores 0, 100 y 101 fijan límites inclusivos y fuera de rango en pct, matando mutaciones como > 100 → >= 100.
-
Constantes y exactitud: verificaciones con valores exactos y redondeo (99.99 → 84.99) evitan aserciones vagas.
-
Errores esperados: se comprueba la excepción adecuada cuando corresponde.
Pseudo-salida de una ejecución
# @pytest.mark.bad\_tests
⠦ Generating mutants
done in 30ms
⠋ Listing all tests
⠦ Running clean tests
done
⠙ Running forced fail test
done
Running mutation testing
⠸ 21/21 🎉 2 🫥 0 ⏰ 0 🤔 0 🙁 19 🔇 0
260.34 mutations/second
pricing.x\_apply\_discount\_\_mutmut\_1: survived
pricing.x\_apply\_discount\_\_mutmut\_2: survived
pricing.x\_apply\_discount\_\_mutmut\_3: survived
pricing.x\_apply\_discount\_\_mutmut\_4: survived
pricing.x\_apply\_discount\_\_mutmut\_5: survived
pricing.x\_apply\_discount\_\_mutmut\_6: survived
pricing.x\_apply\_discount\_\_mutmut\_7: survived
pricing.x\_apply\_discount\_\_mutmut\_8: survived
pricing.x\_apply\_discount\_\_mutmut\_9: survived
pricing.x\_apply\_discount\_\_mutmut\_10: survived
pricing.x\_apply\_discount\_\_mutmut\_11: survived
pricing.x\_apply\_discount\_\_mutmut\_13: survived
pricing.x\_apply\_discount\_\_mutmut\_14: survived
pricing.x\_apply\_discount\_\_mutmut\_15: survived
pricing.x\_apply\_discount\_\_mutmut\_16: survived
pricing.x\_apply\_discount\_\_mutmut\_17: survived
pricing.x\_apply\_discount\_\_mutmut\_18: survived
pricing.x\_apply\_discount\_\_mutmut\_20: survived
pricing.x\_apply\_discount\_\_mutmut\_21: survived
# @pytest.mark.good\_tests
⠦ Generating mutants
done in 31ms
⠋ Listing all tests
⠦ Running clean tests
done
⠙ Running forced fail test
done
Running mutation testing
⠸ 21/21 🎉 21 🫥 0 ⏰ 0 🤔 0 🙁 0 🔇 0
258.25 mutations/second
🎃 Interpretar resultados y actuar
El valor del mutation testing está en la lectura inteligente de sus resultados:
| Estado | Significado | Acción recomendada |
|---|---|---|
| 🎉 Killed | El test falló con la mutación → el cambio fue detectado. | ✅ Ninguna acción necesaria; los tests protegen correctamente el comportamiento. |
| 🙁 Survived | El test pasó pese a la mutación → el cambio no fue detectado. | 🧩 Añadir un test que cubra el caso omitido (borde, combinación lógica, constante). |
| 🫥 Incompetent | El mutante no pudo evaluarse por error de entorno, fallo de importación o crash durante la ejecución. | ⚙️ Revisar la estabilidad del entorno o dependencias. No implica necesariamente un problema en los tests. |
| ⏰ Timeout | El mutante tardó demasiado en ejecutarse o no devolvió resultado. | ⏱️ Analizar si hay bucles, dependencias lentas o tests E2E ejecutándose. Considerar excluirlos del alcance o aumentar el tiempo límite. |
| 🤔 Equivalentes | El código mutado no altera el comportamiento observable (p. ej., a + 0, x * 1). | 📄 Documentar o excluir del cómputo para no sesgar el Mutation Score. |
| 🔇 Silent / Skipped | Mutantes omitidos por reglas de exclusión o porque la línea no está cubierta. | 🎯 Revisar filtros y asegurarse de que las exclusiones sean intencionadas (p. ej., código generado o sin relevancia lógica). |
El objetivo no es alcanzar el 100 %, sino convertir cada survivor en una mejora real de pruebas.
Cuándo aporta valor y cuándo no
Aporta gran valor en:
-
Reglas de negocio y validaciones (precios, permisos, políticas).
-
Módulos rápidos y deterministas (unit o de servicio).
-
Entornos críticos con cambios frecuentes.
Aporta menos en:
-
Pruebas E2E lentas o dependientes de I/O.
-
Código trivial o muy volátil.
Cómo acotar el alcance:
-
Empezar por paquetes core de negocio.
-
Mutar solo líneas cubiertas.
-
Ejecutar pasadas focalizadas en PRs de alto riesgo.
🎃 Coste y mitigación
El coste principal proviene de ejecutar la suite muchas veces.
Para mantener feedback rápido sin perder señal:
-
Incremental: empezar por módulos críticos y ampliar gradualmente.
-
Paralelización y caché: usar núcleos disponibles y resultados por archivo/commit.
-
Mixto: regresiones completas en nightly; parciales durante el día.
-
Basado en cobertura: mutar solo líneas ejecutadas.
Estas estrategias conservan el valor esencial: confianza en que los tests detectan regresiones reales.
🎃 Errores frecuentes y buenas prácticas
-
Aserciones vagas: evitar > 0 o “no lanza excepción”. Preferir valores exactos.
-
Olvidar bordes: incluir límites inclusivos/exclusivos, nulos o vacíos.
-
Over-mocking: uso excesivo de mocks y stubs que sustituyen la lógica real. Mantenerlos para servicios externos; en lógica de negocio, preferir aserciones de valor para “matar” mutantes.
-
Ignorar equivalentes: documentarlos o excluirlos.
-
Grandes ejecuciones: priorizar iteraciones cortas y focalizadas.
🎃 Primeros pasos recomendados
- Contexto y objetivos: alinear criticidad del producto, historial de bugs y metas de calidad/negocio.
- Acotar el alcance: seleccionar un módulo claro (p. ej., cálculo de precios o permisos) y acordar límites/bordes relevantes.
- Ejecutar una pasada de mutación: lanzar mutmut run sobre el módulo y revisar survivors con mutmut results/show.
- Fortalecer las pruebas: añadir casos de borde, constantes y errores esperados; usar valores exactos y redondeos para “matar” survivors.
- Analizar reportes y corregir: interpretar resultados (survived, timeout, incompetent, equivalentes) y ajustar pruebas o exclusiones según corresponda.
- Consolidar y escalar: repetir la pasada hasta estabilizar el módulo y avanzar al siguiente paquete priorizado.
🎃 Conclusión
El mutation testing traslada el foco desde “cuánto ejecutan los tests” hacia “qué protegen de verdad”.
No compite con la cobertura: la vuelve honesta y accionable.
Cada survivor se transforma en una mejora tangible y el Mutation Score refleja la capacidad real del sistema de pruebas para detectar regresiones.
El resultado: calidad sostenible y confianza al evolucionar el producto.
En definitiva, el mutation testing convierte los tests en una herramienta de verdad predictiva, no solo descriptiva: mide la capacidad de tu suite para prevenir regresiones antes de que lleguen al producto*.*
Nuestras últimas novedades
¿Te interesa saber cómo nos adaptamos constantemente a la nueva frontera digital?
Tech Insight
8 de octubre de 2025
Shai‑Hulud: El ataque masivo a npm que sacude la cadena de suministro del software
Insight
31 de julio de 2025
El potencial de Process Mining en entornos SAFe