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:

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

  2. Ejecutar tests contra cada mutante. El test suite completo se ejecuta contra cada mutante.

  3. 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)
  4. 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 ScoreEvaluació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á:

  1. Cambiar price * 0.8 a price * 0.9
  2. Cambiar price * 0.9 a price * 0.8
  3. Cambiar customer_type == "premium" a customer_type != "premium"
  4. Cambiar return price a return 0
  5. Cambiar price * 0.8 a price + 0.8
Solución
  1. Eliminado. test_premium_discount espera 80 pero obtiene 90.
  2. Eliminado. test_regular_discount espera 90 pero obtiene 80.
  3. Eliminado. test_premium_discount entra al branch incorrecto.
  4. Eliminado. test_no_discount espera 100 pero obtiene 0.
  5. Eliminado. test_premium_discount espera 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"
  1. Cambiar age >= 18 a age > 18
  2. Cambiar income > 30000 a income >= 30000
  3. Cambiar has_account a not has_account
  4. Cambiar return "PENDING" a return "APPROVED"
  5. Cambiar and a or en la primera condición
Solución
  1. Sobrevive. El test usa age=25, que satisface tanto >= 18 como > 18.
  2. Sobrevive. El test usa income=50000, que satisface tanto > 30000 como >= 30000.
  3. Eliminado. test_approved ahora entra al branch else y retorna “PENDING”.
  4. Sobrevive. Ningún test alcanza return "PENDING".
  5. 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

  1. Empieza con módulos críticos. No ejecutes mutation testing en todo el codebase inicialmente.
  2. Establece un threshold. Falla el build si el mutation score baja de un objetivo (ej: 80%).
  3. Ejecuta incrementalmente. Solo muta código cambiado en el PR actual.
  4. Ú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