advanced Por Mathias Paulenko

Implementar Mutation Testing

Cómo usar mutation testing con MutPy, Stryker y PIT para evaluar si tus tests realmente asertan comportamiento o simplemente ejecutan código.

Temas: testing

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Descripción General

La cobertura de código te dice qué líneas fueron ejecutadas, pero no si los tests fallarían si el comportamiento cambiara. El mutation testing aborda esto introduciendo bugs pequeños y semánticamente significativos (mutantes) en tu código — cambiando + por -, invirtiendo una condición, eliminando una llamada a método — y verificando que al menos un test falle. Un alto mutation score significa que tus tests genuinamente asertan comportamiento, no solo pasan a través del código.

Cuándo Usar

  • For alternatives, see Generate Test Data.

  • La cobertura es alta (80%+) pero bugs aún llegan a producción

  • Sospechas que los tests carecen de assertions significativas (tests mock-heavy que no verifican nada)

  • La lógica de negocio crítica necesita confianza más allá de la cobertura de líneas

  • Estás refactorizando código legacy y quieres asegurar que los tests atrapen regresiones

  • Las revisiones de código descubren repetidamente “este test pasa incluso si borro la implementación”

Cuándo NO Usar

  • El codebase tiene baja cobertura para empezar — arregla la cobertura antes del mutation testing
  • Los suites de test ya tardan 30+ minutos — el mutation testing multiplica ese tiempo considerablemente
  • Estás en fase de prototipo temprano y los tests son intencionalmente mínimos
  • El equipo no tiene tiempo para investigar y fortalecer tests que no matan mutantes

Implementación Paso a Paso

Python (MutPy)

# Instalar
pip install mutpy

# Ejecutar mutation testing en un módulo
mutpy --target mymodule --unit-test tests/ --runner pytest

# Generar reporte HTML
mutpy --target mymodule --unit-test tests/ --runner pytest --report-html mutpy_report/

# Mostrar mutantes sobrevivientes (tests que deberían haber fallado pero no)
mutpy --target mymodule --unit-test tests/ --runner pytest --show-mutants
# Configuración .mutpy.yml
target:
  - myproject/core/
tests:
  - tests/core/
runner: pytest
show_mutants: true
exclude:
  - "*/migrations/*"
  - "*/tests/*"

# Operadores de mutación a aplicar
operators:
  - AOR  # Reemplazo de operador aritmético (+ por -, etc.)
  - ROR  # Reemplazo de operador relacional (> por >=, etc.)
  - COR  # Reemplazo de operador condicional (and por or)
  - UOI  # Inserción/eliminación de operador unario
  - ABS  # Inserción de valor absoluto
# Ejemplo: un test que sobrevive mutación (malo)
def test_calculate_discount():
    result = calculate_discount(100, 0.2)
    # Sin assertion — cualquier mutante sobrevive

# Test fortalecido que mata mutantes
def test_calculate_discount():
    result = calculate_discount(100, 0.2)
    assert result == 80  # Mutantes que devuelven 81, 79, 100, 0 fallarán
    assert isinstance(result, (int, float))

JavaScript (Stryker)

# Instalar
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner

# Inicializar configuración
npx stryker init
// stryker.config.mjs
export default {
  testRunner: 'jest',
  reporters: ['progress', 'clear-text', 'html'],
  mutate: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/vendor/**'
  ],
  coverageAnalysis: 'perTest',
  thresholds: {
    high: 80,
    low: 60,
    break: 50    # CI falla bajo 50% de mutation score
  }
};
# Ejecutar mutation testing
npx stryker run

# Modo check (más rápido, no genera reporte completo)
npx stryker run --check

# Dashboard reporter para comentarios en PR
npx stryker run --reporters dashboard
// Ejemplo: detección de mutante sobreviviente
// Código original
function isEligible(age, income) {
  return age >= 18 && income > 30000;  # Stryker muta >= a >
}

// Test débil (sobrevive el mutante >= a >)
test('eligibility', () => {
  expect(isEligible(18, 40000)).toBe(true);
});

// Test fuerte (mata el mutante)
test('eligibility boundary', () => {
  expect(isEligible(18, 30000)).toBe(false);  # income > 30000 falla aquí
  expect(isEligible(17, 40000)).toBe(false);  # age >= 18 falla aquí
  expect(isEligible(18, 40000)).toBe(true);
});

Java (PIT)

<!-- pom.xml -->
<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.15.0</version>
  <configuration>
    <targetClasses>
      <param>com.example.core.*</param>
    </targetClasses>
    <targetTests>
      <param>com.example.core.*Test</param>
    </targetTests>
    <mutators>
      <mutator>CONDITIONALS_BOUNDARY</mutator>
      <mutator>MATH</mutator>
      <mutator>NEGATE_CONDITIONALS</mutator>
      <mutator>RETURN_VALS</mutator>
      <mutator>VOID_METHOD_CALLS</mutator>
    </mutators>
    <thresholds>
      <mutation>70</mutation>
      <coverage>80</coverage>
    </thresholds>
    <outputFormats>
      <format>HTML</format>
      <format>XML</format>
    </outputFormats>
  </configuration>
</plugin>
# Ejecutar PIT mutation testing
mvn org.pitest:pitest-maven:mutationCoverage

# Más rápido con análisis incremental (solo código cambiado)
mvn org.pitest:pitest-maven:mutationCoverage -DwithHistory

Interpretando Resultados

TérminoSignificado
MutanteUna versión modificada de tu código con un cambio semántico
KilledAl menos un test falló en el mutante — bueno
SurvivedTodos los tests pasaron en el mutante — gap de test encontrado
TimeoutEl mutante causó un loop infinito o slowdown extremo
EquivalentEl mutante se comporta idénticamente al original (falso positivo)
Mutation ScoreKilled / (Killed + Survived) × 100
# Ejemplo de output de Stryker
Ran 12.4k mutants in 4m 32s
- Killed: 10,210 (82%)
- Survived: 2,134 (17%)
- Timed out: 56 (<1%)
- Equivalent: ~120 (excluded from score)

Mutantes sobrevivientes:
src/cart.js:45  # cambiado >= a > en calculateTotal
src/cart.js:67  # removido null check en applyDiscount

Avanzado: Configurar Mutators para Testing Dirigido

Cada herramienta de mutation testing soporta diferentes sets de mutators. Elegir los mutators correctos reduce el runtime y se enfoca en mutaciones significativas:

<!-- PIT: solo mutators de lógica de negocio, omitir triviales -->
<mutators>
  <mutator>CONDITIONALS_BOUNDARY</mutator>
  <mutator>NEGATE_CONDITIONALS</mutator>
  <mutator>MATH</mutator>
  <mutator>RETURN_VALS</mutator>
  <mutator>VOID_METHOD_CALLS</mutator>
  <!-- Omitir: INCREMENTS, EMPTY_RETURNS — bajo valor, alto ruido -->
</mutators>
// Stryker JS: configurar mutators
{
  "mutator": {
    "excludedMutations": ["StringLiteral", "BooleanLiteral"]
  }
}

Excluye mutaciones StringLiteral y BooleanLiteral cuando tus tests usan esos valores para setup, no para aserciones. Incluirlos produce mutantes sobrevivientes que no representan gaps reales de test.

Lo que funciona

  • Apunta al código de alto valor primero. Ejecuta mutation testing en la lógica de negocio core, no en el wiring de controladores o mapeos de DTO. El mutation testing es costoso; enfócalo donde importa.
  • Distingue mutantes equivalentes de gaps reales. Un mutante equivalente (a + 0 cambiado a a - 0) no puede ser matado. Márcalos en la configuración para evitar ruido.
  • Usa modo incremental en CI. El history mode de PIT y el análisis incremental de Stryker solo mutan archivos cambiados, reduciendo el tiempo de horas a minutos.
  • Configura thresholds realistas. Un 100% de mutation score usualmente no vale el esfuerzo. 70-80% en módulos core es una señal fuerte de calidad de test.
  • Trata mutantes sobrevivientes como tickets. Cada mutante sobreviviente es un bug potencial oculto en producción. Priorízalos como comentarios de code review.

Errores Comunes

  • Ejecutar mutation tests en todo el suite sin filtrar. Un codebase grande puede tardar horas. Comienza con un paquete o módulo.
  • Perseguir 100% de mutation score. El último 10% a menudo requiere testear getters triviales o llamadas de logging que no proveen valor de negocio.
  • Ignorar mutantes equivalentes. Crean ruido y hacen que los desarrolladores desconfíen de la herramienta. Configura exclusiones o anotaciones.
  • Usar mutation score como KPI de equipo. Incentiva escribir tests específicamente para matar mutantes en lugar de testear requerimientos reales.
  • Ejecutar mutation tests en tests de integración sin mock. Las llamadas a base de datos y requests HTTP hacen el mutation testing imposible de lento; apunta a tests unitarios.

Referencia Rápida

  • Comando principal: ejecuta la solución base del artículo y verifica el resultado esperado.
  • Validación: confirma que los tests pasan y que las métricas clave no se degradaron.
  • Rollback: si algo falla, revierte el cambio y consulta la sección de Troubleshooting.

Lectura Adicional

  • Documentación oficial: consulta la referencia actualizada del framework o herramienta utilizada.
  • Guías relacionadas: explora las guías de testing y quality para profundizar.
  • Patrones complementarios: revisa los patrones de diseño aplicables a tu stack tecnológico.
  • Postmortems públicos: estudia incidentes reales de equipos que enfrentaron problemas similares en producción.

Notas de Producción

  • Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
  • Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
  • Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
  • Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.

Puntos Clave

  • Aplica implementar mutation testing cuando necesites una solución práctica para tu caso de uso.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

Troubleshooting

  • Flaky tests: isolate shared state, time, and randomness. Make tests independent and deterministic; quarantine persistently flaky tests.
  • High coverage but bugs in production: coverage does not guarantee correctness. Add mutation testing, property-based tests, or contract tests.
  • Slow test suite: parallelize, mock slow dependencies, and avoid end-to-end tests for logic that can be unit tested.
  • Tests pass locally but fail in CI: check environment differences, timezone, locale, and dependency versions. Pin tool versions.
  • Debugging a failing integration test: Reset state before each test.

Errores Comunes en Producción

  • Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
  • Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
  • Codificar valores fijos que deberían ser configurables por entorno.
  • Olvidar agregar logging y monitoreo en cada paso.
  • Desplegar sin plan de rollback ni estrategia de backup probada.
  • Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
  • No documentar la versión y configuración usadas en producción.
  • Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.

Preguntas frecuentes

¿En qué se diferencia el mutation testing de la cobertura de código?
La cobertura mide qué líneas se ejecutaron. El mutation testing mide si los tests pueden detectar pequeños bugs artificiales (mutantes) introducidos en el código.
¿Qué significa un mutante sobreviviente?
Un mutante sobreviviente significa que la suite de tests no falló después de la mutación, lo que sugiere una aserción faltante o débil. Un mutante muerto significa que los tests detectaron el cambio.
¿Por qué el mutation testing es lento?
Ejecuta la suite completa de tests contra cada mutante. Para bases de código grandes, pueden ser miles de ejecuciones. Las herramientas soportan ejecución incremental y paralela para mitigarlo.