Tus pruebas pasan. Tu reporte de coverage dice 87%. Pero tu mutation score es del 40%, y la mitad de tus mutants siguen vivos.

Ese 40% no significa que tu código esté roto. Significa que tus pruebas lo están. La coverage mide qué líneas se ejecutaron durante una corrida de pruebas. El mutation testing mide si tus pruebas se darían cuenta si esas líneas empezaran a hacer lo incorrecto. Un mutation score del 40% significa que el 60% de los bugs que podrían haberse introducido en tu código habrían pasado directamente por CI.

Qué es en realidad un surviving mutant

Un surviving mutant es un bug pequeño y artificial que tus pruebas no lograron detectar.

Las herramientas de mutation testing funcionan tomando tu código fuente y aplicando un conjunto de transformaciones predefinidas, una a la vez. Pueden cambiar un > por >=, un + por -, o reemplazar una condición booleana por true. Cada versión transformada de tu código es un mutant. La herramienta ejecuta tu suite de pruebas contra cada mutant. Si alguna prueba falla, el mutant es “killed”. Si todas las pruebas pasan, el mutant “survives”.

Un surviving mutant significa una de dos cosas. O tus pruebas no verifican realmente el comportamiento que el mutant rompió, o el mutant es “equivalent” (la transformación produce código semánticamente idéntico, que es un problema conocido y difícil en mutation testing).

La mayoría de los survivors no son equivalentes. La mayoría son bugs caminando.

Un ejemplo concreto: el validador de contraseñas

Aquí hay una función que verifica si una contraseña cumple con los requisitos de la política:

// password.js
function isValidPassword(password) {
  if (password.length < 8) {
    return false;
  }
  if (!/[A-Z]/.test(password)) {
    return false;
  }
  if (!/[0-9]/.test(password)) {
    return false;
  }
  return true;
}

module.exports = { isValidPassword };

Y aquí hay una suite de pruebas que te da 100% de line coverage:

// password.test.js
const { isValidPassword } = require('./password');

test('accepts a valid password', () => {
  expect(isValidPassword('Hello1')).toBe(true);
});

test('rejects a short password', () => {
  expect(isValidPassword('Hi1')).toBe(false);
});

test('rejects a password without uppercase', () => {
  expect(isValidPassword('hello1')).toBe(false);
});

test('rejects a password without a digit', () => {
  expect(isValidPassword('Hellooo')).toBe(false);
});

Espera. isValidPassword('Hello1') devuelve true, pero 'Hello1' solo tiene seis caracteres. La primera verificación debería rechazarla. La prueba está mal, pero pasa porque la propia prueba está afirmando el comportamiento incorrecto.

Una herramienta de mutation testing como Stryker detectaría esto. Una de sus mutaciones cambiaría < por <= en la verificación de longitud. Ese mutant survive porque las pruebas existentes no verifican realmente el límite en 8 caracteres. Otra mutación podría eliminar el bloque if completo. Ese mutant también survive, porque las pruebas no incluyen una contraseña de ocho caracteres sin una letra mayúscula o un dígito. El límite superior de longitud nunca se prueba en combinación con las otras reglas.

Aquí hay una suite de pruebas que realmente mata esos mutants:

// password.test.js
const { isValidPassword } = require('./password');

test('rejects password shorter than 8 chars', () => {
  expect(isValidPassword('Hello1')).toBe(false);
});

test('accepts password exactly 8 chars with uppercase and digit', () => {
  expect(isValidPassword('Hello1!@')).toBe(true);
});

test('rejects password without uppercase', () => {
  expect(isValidPassword('hello1!@')).toBe(false);
});

test('rejects password without digit', () => {
  expect(isValidPassword('Helloooo')).toBe(false);
});

test('rejects password missing both uppercase and digit', () => {
  expect(isValidPassword('helloooo')).toBe(false);
});

Ahora el límite en 8 está explícitamente probado. El mutant <= falla porque 'Hello1!@' (8 caracteres) debe ser aceptada. El mutant de eliminación falla porque 'helloooo' se escaparía sin la verificación de longitud.

Cómo funciona el mutation testing bajo el capó

El mutation testing es computacionalmente costoso porque ejecuta tu suite de pruebas completa una vez por mutant.

Si tu codebase tiene 10.000 líneas y tu herramienta de mutación genera 3.000 mutants, eso son 3.000 ejecuciones de la suite de pruebas. Las implementaciones académicas tempranas eran esencialmente inutilizables en codebases reales por esta razón. Las herramientas modernas se han vuelto más inteligentes.

Stryker, el framework de mutation testing más ampliamente usado para JavaScript y TypeScript, utiliza varias optimizaciones:

  1. Mutant scoping: Stryker solo ejecuta el subconjunto de pruebas que podrían alcanzar la línea mutada, basándose en datos de coverage de una corrida seca inicial.

  2. Parallel execution: Los mutants se evalúan en procesos worker.

  3. Incremental mode: Stryker cachea resultados y solo reevalúa mutants para código que cambió desde la última corrida.

  4. Checkers: Para lenguajes compilados, Stryker puede verificar mutants a nivel de AST sin recompilar todo el proyecto.

Incluso con estas optimizaciones, una corrida completa de mutation testing en un codebase grande puede seguir tomando 10-30 minutos. Por eso la mayoría de los equipos ejecutan mutation testing en CI en pull requests o builds nocturnas, no en cada guardado.

Los trade-offs de los que nadie habla

El mutation testing no es gratis, y no siempre es la herramienta correcta.

El equivalent mutant problem es la limitación teórica más grande. Algunas mutaciones no cambian el comportamiento observable. Considera:

const timeout = 1000 * 60;

Una mutación que cambia esto a 1000 * 61 es semánticamente diferente. Pero una mutación que lo cambia a 60 * 1000 es equivalent. Ninguna prueba puede matarla porque el valor es idéntico. Distinguir mutants equivalentes de survivors genuinos es indecidible en el caso general. Las herramientas modernas usan heurísticas para saltar casos obvios, pero seguirás viendo algunos.

El performance es real. En un proyecto mediano de TypeScript, Stryker podría generar 2.000 mutants y tomar 15 minutos en evaluarlos. Eso son 15 minutos de tiempo de CI en cada corrida si lo habilitas para pull requests. Los equipos típicamente empiezan con un threshold (digamos, fallar el build si el mutation score baja del 60%) y ejecutan el análisis completo cada noche.

La falsa confianza corta por los dos lados. Un mutation score del 100% no significa que tu código no tenga bugs. Significa que ningún bug que coincida con los mutation operators de la herramienta se habría escapado. El mutation testing no puede inventar bugs que no sepa cómo crear. No detectará errores lógicos en tus requerimientos, race conditions que no pueda simular, o fallas de integración a través de los límites de servicio.

Cómo empezar a usar mutation testing de verdad

Si estás escribiendo JavaScript o TypeScript, Stryker es el lugar para empezar.

Instálalo:

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

Crea stryker.config.mjs:

// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  packageManager: 'npm',
  reporters: ['html', 'clear-text', 'progress'],
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  mutate: ['src/**/*.js'],
  threshold: {
    break: 60,
  },
};

export default config;

Ejecútalo:

npx stryker run

Empieza mirando el reporte HTML, no el score. El reporte muestra cada surviving mutant en línea con tu código fuente. Lee los primeros diez survivors. Para cada uno, pregúntate: ¿un bug real en esta ubicación causaría un problema en producción? Si sí, escribe una prueba que lo detecte. Si no, considera si el código está sobreingenierizado.

No persigas el 100%. En un codebase maduro, 70-80% es un score fuerte. Por debajo del 50%, probablemente tienes pruebas que ejecutan código sin afirmar nada significativo. Por encima del 90%, es probable que estés encontrando rendimientos decrecientes y un creciente equivalent-mutant tax.

Qué hacer con tu 40%

Un mutation score del 40% es un regalo. Te dice exactamente dónde tus pruebas son decorativas.

Elige los tres archivos con más surviving mutants. Lee cada survivor y pregunta qué aserción falta. A menudo la solución es simple: llamaste a una función en una prueba pero nunca verificaste el valor de retorno. O pasaste datos por un parser pero nunca verificaste la salida parseada. O probaste el happy path tres veces con diferentes inputs pero nunca probaste la branch de error.

Los mutants no son ruido. Son una lista clasificada de los lugares más probables donde un bug no probado puede esconderse. Empieza por el principio.


FAQ

¿Cuál es la diferencia entre code coverage y mutation testing? La code coverage mide qué líneas se ejecutaron. El mutation testing mide si tus pruebas fallarían si esas líneas contuvieran un bug. 100% de coverage con 40% de mutation score significa que ejecutaste cada línea, pero tus pruebas no se darían cuenta si la mayoría estuvieran mal.

¿Puede el mutation testing encontrar bugs en mi código existente? No. El mutation testing evalúa tus pruebas, no tu código fuente. Te dice dónde tus pruebas son insuficientes. No te dice si tu código es correcto, solo si tus pruebas detectarían ciertas clases de errores.

¿Qué lenguajes tienen buenas herramientas de mutation testing? JavaScript/TypeScript (Stryker), Java (PIT), C# (Stryker.NET), Python (mutmut) y Rust (cargo-mutants) tienen herramientas maduras. El ecosistema varía en performance y mutation operators soportados.

¿Debería el mutation testing reemplazar la code coverage? No. La coverage es barata y rápida. Úsala para feedback rápido durante el desarrollo. Usa el mutation testing como una quality gate periódica para encontrar los puntos ciegos que la coverage no puede ver.