Mutation Testing: Mide la efectividad real de tus pruebas

Mutation Testing: Mide la efectividad real de tus pruebas

Alejandro Pena, Chapter Lead Backend & QE

Alejandro Pena

Chapter Lead Backend & QE

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.

Mutationtesting-interfaz.jpg

🔹 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.

Mutationtesting-badtest.jpg

🎃 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%

Mutationtesting-funcinobjetivo.jpg

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

Mutationtesting05.jpg

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

Mutationtesting06.jpg

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

  1. Contexto y objetivos: alinear criticidad del producto, historial de bugs y metas de calidad/negocio.
  2. Acotar el alcance: seleccionar un módulo claro (p. ej., cálculo de precios o permisos) y acordar límites/bordes relevantes.
  3. Ejecutar una pasada de mutación: lanzar mutmut run sobre el módulo y revisar survivors con mutmut results/show.
  4. Fortalecer las pruebas: añadir casos de borde, constantes y errores esperados; usar valores exactos y redondeos para “matar” survivors.
  5. Analizar reportes y corregir: interpretar resultados (survived, timeout, incompetent, equivalentes) y ajustar pruebas o exclusiones según corresponda.
  6. 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*.*

Alejandro Pena, Chapter Lead Backend & QE

Alejandro Pena

Chapter Lead Backend & QE

Con más de 16 años de experiencia en Ingeniería de Software, mi especialidad gira en torno al Backend, QE y DevOps. Siempre me han apasionado las nuevas tecnologías, el mundo del desarrollo y los videojuegos. En mi tiempo libre me dedico a disfrutar de la naturaleza con mi familia, el cine, la literatura o la música.


Nuestras últimas novedades

¿Te interesa saber cómo nos adaptamos constantemente a la nueva frontera digital?

Atlassian Site Optimizer
Atlassian Site Optimizer

Tech Insight

27 de octubre de 2025

Atlassian Site Optimizer

Atlassian Software Collection
Atlassian Software Collection

Tech Insight

20 de octubre de 2025

Atlassian Software Collection

Shai‑Hulud: El ataque masivo a npm que sacude la cadena de suministro del software
Shai‑Hulud: El ataque masivo a npm que sacude la cadena de suministro del software

Tech Insight

8 de octubre de 2025

Shai‑Hulud: El ataque masivo a npm que sacude la cadena de suministro del software

El potencial de Process Mining en entornos SAFe
El potencial de Process Mining en entornos SAFe

Insight

31 de julio de 2025

El potencial de Process Mining en entornos SAFe