StackPractices
advanced Por Mathias Paulenko

Implementar Mutation Testing

Cómo usar mutation testing con mutmut, Stryker y PIT para evaluar si tus tests realmente verifican el comportamiento o solo ejecutan código.

Temas: testing

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 resuelve 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 mutation score alto significa que tus tests verifican comportamiento de verdad, no solo recorren el código.

Cómo Funciona el Mutation Testing

Toda herramienta ejecuta el mismo ciclo: genera mutantes, corre la suite contra cada uno y cuenta cuántos sobreviven.

Mermaid flowchart LR diagram

El paso C es el costoso — la suite corre una vez por mutante — y por eso el scoping y los modos incrementales importan tanto en la práctica.

Cuándo Usar

  • La cobertura es alta (80%+) pero los bugs igual llegan a producción — si no tienes una línea base, primero mide la cobertura de tests
  • Sospechas que los tests carecen de aserciones significativas (tests con muchos mocks 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 pruebas de que los tests atrapan regresiones
  • Las revisiones de código sacan a la luz una y otra vez “este test pasa aunque borre la implementación”

Cuándo NO Usar

  • La base de código tiene cobertura baja para empezar — arregla la cobertura antes del mutation testing
  • Las suites de tests ya tardan más de 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 reforzar los tests que no matan mutantes

Implementación Paso a Paso

Python (mutmut)

Los tutoriales más antiguos recomiendan MutPy, pero no tiene releases desde 2019 y apunta a Python 3.7 y anteriores. Para bases de código actuales usa mutmut, que funciona con Python moderno y pytest.

# Instalar
pip install mutmut

# Ejecutar mutation testing — detecta tests en tests/ o test/ automáticamente
mutmut run

# Inspeccionar mutantes en una TUI interactiva; presiona 'r' para re-testear tras corregir
mutmut browse

# Resumen de conteos y luego el diff de un sobreviviente concreto
mutmut results
mutmut show 42
# pyproject.toml — define qué se muta
[tool.mutmut]
source_paths = ["src/"]
pytest_add_cli_args_test_selection = ["tests/"]
# Ejemplo: un test que sobrevive a la mutación (malo)
def test_calculate_discount():
    result = calculate_discount(100, 0.2)
    # Sin aserción — cualquier mutante sobrevive

# Test reforzado que mata mutantes
def test_calculate_discount():
    result = calculate_discount(100, 0.2)
    assert result == 80  # Los 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 la 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 por debajo del 50% de mutation score
  }
};
# Ejecutar mutation testing
npx stryker run

# Modo incremental — re-ejecuta solo lo que cambió, viable en PRs
npx stryker run --incremental

# Reporter de dashboard 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 al mutante >= a >)
test('eligibility', () => {
  expect(isEligible(18, 40000)).toBe(true);
});

// Test fuerte (mata al 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 modificado)
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ó con el mutante — bien
SurvivedTodos los tests pasaron con el mutante — gap de test encontrado
TimeoutEl mutante causó un bucle infinito o una lentitud extrema
EquivalentEl mutante se comporta igual que el original (falso positivo)
Mutation ScoreKilled / (Killed + Survived) × 100
# Ejemplo de salida 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  # cambió >= a > en calculateTotal
src/cart.js:67  # eliminó el null check en applyDiscount

Avanzado: Configurar Mutators para Testing Dirigido

Cada herramienta de mutation testing soporta conjuntos de mutators distintos. Elegir los 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 — poco valor, mucho ruido -->
</mutators>
// Stryker JS: configurar mutators
{
  "mutator": {
    "excludedMutations": ["StringLiteral", "BooleanLiteral"]
  }
}

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

Lo que funciona

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

Errores Comunes

  • Ejecutar mutation tests sobre toda la suite sin filtrar. Una base de código grande puede tardar horas. Empieza con un paquete o módulo.
  • Perseguir el 100% de mutation score. El último 10% suele requerir testear getters triviales o llamadas de logging sin valor de negocio.
  • Ignorar los mutantes equivalentes. Generan ruido y hacen que el equipo desconfíe de la herramienta. Configura exclusiones o anotaciones.
  • Usar el mutation score como KPI del equipo. Incentiva escribir tests para matar mutantes en lugar de tests que verifican requerimientos reales.
  • Ejecutar mutation tests sobre tests de integración sin mocks. Las llamadas a base de datos y los requests HTTP hacen el proceso imposiblemente lento; apunta a tests unitarios.

Troubleshooting

  • La ejecución de mutantes tarda más que la suite de tests completa. Reduce el alcance antes de optimizar: apunta source_paths (mutmut), mutate (Stryker) o targetClasses (PIT) a un solo paquete y excluye código generado, migraciones y wiring de configuración. Después activa el modo incremental para no re-mutar archivos sin cambios.
  • Los sobrevivientes se acumulan en código trivial. Los getters, los métodos toString() y las llamadas de logging sobreviven porque nadie hace aserciones sobre ellos — y probablemente no haga falta. Excluye esos archivos de los patrones de mutación en vez de escribir tests sin valor.
  • Los tests flaky generan resultados falsos. Un test que pasa o falla al azar hace que cada mutante que toca parezca killed o survived al azar. Pon en cuarentena los tests inestables antes de confiar en un reporte de mutación; una vez que la suite es determinista, los tests basados en propiedades son un buen complemento.
  • El score se estanca por debajo del threshold. Revisa cuántos sobrevivientes son mutantes equivalentes — mutaciones que no pueden cambiar el comportamiento, como a + 0 → a - 0. Exclúyelos en la configuración en lugar de escribir tests para casos imposibles.
  • PIT o Stryker se quedan sin memoria en módulos grandes. Baja threads/concurrency y divide la ejecución por módulo; el mutation testing mantiene muchas variantes de bytecode o AST en memoria a la vez.

Referencia Rápida

Tareamutmut (Python)Stryker (JS/TS)PIT (Java/JVM)
Instalarpip install mutmutnpm i -D @stryker-mutator/corePlugin Maven en pom.xml
Ejecutarmutmut runnpx stryker runmvn org.pitest:pitest-maven:mutationCoverage
Incrementalreejecutar mutmut run (resultados en caché)npx stryker run --incrementalmutationCoverage -DwithHistory
Inspeccionar un sobrevivientemutmut browse / mutmut show <id>Reporte HTML en reports/HTML en target/pit-reports/
Archivo de configuraciónpyproject.toml [tool.mutmut]stryker.config.mjsBloque <plugin> en pom.xml

Lectura Adicional

Puntos Clave

  • La cobertura mide qué líneas se ejecutaron; el mutation score mide si tus aserciones detectarían un cambio real de comportamiento. Controla lo segundo, no solo lo primero.
  • Elige la herramienta según el stack: mutmut para Python, Stryker para JavaScript y TypeScript, PIT para la JVM.
  • Limita las ejecuciones a la lógica de negocio y apunta a un 70-80% ahí — el resto de mutantes suele ser equivalente o cubrir código sin comportamiento observable.
  • Cada mutante sobreviviente es un gap de test concreto y reproducible. Corrige la aserción o el caso de borde faltante en lugar de ajustar el score.
  • Ejecuta mutation testing incremental en los pull requests y programa la ejecución completa de noche; el gating solo tiene sentido en módulos con tests estables y maduros.

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 — testea los tests, no el código.

¿Qué significa un mutante sobreviviente?

Un mutante sobreviviente significa que la suite de tests no falló tras la mutación, lo que sugiere una aserción faltante o débil. Un mutante eliminado significa que los tests detectaron el cambio.

¿Por qué el mutation testing es lento?

Porque ejecuta la suite completa de tests contra cada mutante. En bases de código grandes eso puede significar miles de ejecuciones. Las herramientas ofrecen ejecución incremental y paralela para mitigarlo.

¿Cómo reduzco el runtime de mutation testing en CI?

Usa el modo incremental (-DwithHistory de PIT, --incremental de Stryker, la caché de resultados de mutmut) para mutar solo archivos modificados. Ejecuta el análisis completo en PRs que tocan módulos centrales y programa ejecuciones completas nocturnas. Paraleliza con --concurrency en Stryker o el ajuste threads de PIT. Apunta solo a los paquetes con lógica de negocio, no a toda la base de código.

¿Qué son los mutantes equivalentes y cómo los manejo?

Los mutantes equivalentes son mutaciones que producen código semánticamente idéntico — los tests no pueden matarlos porque el comportamiento no cambia. Ejemplos: a + 0 vs a - 0, o true || x vs true. Márcalos en los archivos de configuración (excludedMutators de PIT, excludedMutations de Stryker) o anótalos en el reporte. No persigas un 100% cuando los mutantes equivalentes inflan el denominador.

¿Debo usar el mutation score como gate de CI?

Solo en módulos con tests estables y maduros. Define un threshold (por ejemplo, 70%) y haz fallar el build cuando el score baje de ese valor. Para código nuevo o módulos en desarrollo activo, usa el mutation testing como herramienta de reporte, no como gate. Bloquear demasiado pronto incentiva escribir tests que matan mutantes en lugar de tests que verifican requerimientos reales.

¿Cómo integro mutation testing con GitHub Actions?

Ejecuta mutation testing en PRs que toquen módulos centrales. Guarda en caché el historial de mutaciones entre ejecuciones con actions/cache. Publica el mutation score como comentario del PR con una GitHub Action personalizada. Programa una ejecución completa nocturna sobre la rama principal para detectar regresiones.