Mutation testing: la técnica que revela tests que no prueban nada
Un test puede pasar, tener cobertura del 100% y no detectar nada si el código se rompe. Mutation testing introduce bugs deliberados para comprobarlo. Así funciona en el ecosistema JavaScript y TypeScript.
El coverage lleva más de dos décadas siendo el número que enseñamos en el dashboard de CI para decir “esto está bien probado”. El problema es que mide algo mucho más limitado de lo que su nombre sugiere: qué líneas de código se ejecutaron durante los tests, no si esos tests detectarían un fallo si esas líneas tuvieran un bug. Un test sin una sola aserción puede dar 100% de cobertura sobre la función que ejecuta. Es un caso extremo, pero la versión sutil de ese mismo problema —aserciones demasiado débiles para detectar un cambio real de comportamiento— está presente en muchas más suites de las que sus equipos creen. Mutation testing es la técnica diseñada específicamente para exponerlo.
Qué es mutation testing
La idea es mecánica y, precisamente por eso, muy difícil de engañar: una herramienta de mutation testing introduce automáticamente pequeños cambios deliberados en tu código de producción —llamados mutantes— y ejecuta tu suite de tests contra cada versión mutada, una por una.
Cada mutante puede terminar en uno de estos estados:
- Killed (matado): al menos un test falló con el mutante activo. Es el resultado deseado: tus tests detectaron el cambio de comportamiento.
- Survived (sobrevivido): todos los tests siguieron pasando con el código mutado. Esto es una alarma: significa que existe un cambio de comportamiento que tu suite no detecta.
- No coverage: el mutante cae en una línea que ningún test ejecuta. Sobrevive por ausencia total de cobertura, no porque el test falle en detectarlo.
- Timeout: el mutante provoca, por ejemplo, un bucle infinito. Se cuenta como detectado, porque en un pipeline real ese comportamiento también haría saltar una alarma (el build se cuelga o excede el tiempo límite).
A partir de ahí, el mutation score se calcula como el porcentaje de mutantes detectados sobre el total de mutantes válidos: detected / valid * 100. Cuanto más alto, más capacidad real tiene tu suite de detectar cambios de comportamiento genuinos, no solo de “tocar” el código.
Cómo detecta tests que no prueban nada
El ejemplo más ilustrativo es también el más simple. Imagina esta función y su test:
// mayoriaDeEdad.ts
export function esMayorDeEdad(edad: number): boolean {
return edad >= 18;
}
// mayoriaDeEdad.test.ts
import { describe, it, expect } from 'vitest';
import { esMayorDeEdad } from './mayoriaDeEdad';
describe('esMayorDeEdad', () => {
it('devuelve true para un adulto', () => {
expect(esMayorDeEdad(25)).toBe(true);
});
});
Este test da cobertura del 100% de la función y pasa sin problemas. Pero no comprueba el caso que realmente importa: el límite. Una herramienta de mutation testing generaría, entre otros, estos mutantes sobre esa línea:
return edad > 18; // mutante: >= cambiado a >
return edad <= 18; // mutante: >= cambiado a <=
return true; // mutante: valor de retorno fijo
El primer mutante (> en vez de >=) sobrevive: con edad 25, tanto edad >= 18 como edad > 18 devuelven true, así que el test sigue pasando exactamente igual con el bug introducido. La suite nunca prueba el valor 18 exacto, así que no puede distinguir entre “mayor o igual” y “estrictamente mayor”. El mutation score revela ese hueco de forma automática y precisa, señalando la línea exacta y el mutante concreto que sobrevivió — algo que el número de coverage, por sí solo, nunca iba a mostrar porque la línea sí se ejecutó.
Añadir un test para el límite (esMayorDeEdad(18) debe ser true, esMayorDeEdad(17) debe ser false) mata ese mutante y cierra el hueco real de verificación, no solo el hueco de ejecución.
Herramientas del ecosistema JavaScript y TypeScript
StrykerJS es la herramienta de referencia para mutation testing en JavaScript y TypeScript, parte del proyecto Stryker Mutator (que también da soporte a .NET y Scala bajo el mismo concepto). Sus puntos fuertes:
- Soporta como test runner Jest, Vitest, Mocha, Karma y Jasmine de forma nativa, además de Cucumber y Tap, por lo que no obliga a cambiar de runner para adoptarlo.
- El paquete
@stryker-mutator/typescript-checkerverifica que cada mutante siga siendo válido a nivel de tipos antes de gastar tiempo ejecutando tests contra un mutante que ni siquiera compilaría, lo que reduce ruido en proyectos TypeScript. - Genera un reporte HTML navegable con cada archivo, cada mutante y su estado, útil tanto para auditar como para justificar en qué módulos merece la pena invertir más tests.
Para arrancar en un proyecto existente:
npm init stryker@latest
El asistente detecta el test runner ya instalado (Vitest, Jest…) y genera un stryker.conf.json de partida. Una configuración típica sobre un proyecto con Vitest:
{
"testRunner": "vitest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"thresholds": { "high": 80, "low": 60, "break": 50 },
"reporters": ["html", "progress"]
}
El campo break es el que convierte esto en una puerta de calidad real: si el mutation score cae por debajo de ese umbral, Stryker termina con código de salida distinto de cero, y puedes hacer que el pipeline de CI falle igual que fallaría por un test roto.
El coste real de introducirlo
Aquí es donde mutation testing deja de ser gratis, y conviene ser honesto sobre ello antes de proponerlo en un equipo. Ejecutar la suite completa una vez por cada mutante generado es, por diseño, mucho más caro en tiempo de cómputo que ejecutarla una sola vez. Un módulo con unos pocos cientos de líneas puede generar varios cientos de mutantes; multiplicar el tiempo de tu suite por esa cifra deja de ser trivial rápidamente en proyectos medianos o grandes.
Las mitigaciones prácticas que hacen esto viable en la práctica:
- Modo incremental. Stryker puede limitarse a mutar solo los archivos que cambiaron desde la última ejecución, apoyándose en un fichero de resultados previos. En un pipeline de PR, esto reduce el coste de minutos u horas a segundos o pocos minutos en el caso habitual.
- No lo apliques a todo el código por igual. Priorizar módulos con lógica de negocio densa, cálculos, validaciones o reglas con muchos casos borde da mucho más valor por minuto de CI que aplicarlo indiscriminadamente a capas triviales (getters, mapeos directos de datos, configuración).
- Ejecútalo con una cadencia distinta a la del resto de tests. No tiene por qué correr en cada commit. Un job nocturno o semanal sobre el código crítico, o un job en el pipeline de PR limitado solo a los archivos modificados en modo incremental, suele ser el punto de equilibrio razonable entre señal y coste de CI.
- Ajusta el umbral de ruptura de forma realista y progresiva. Empezar con
break: 50sobre una suite existente y subirlo con el tiempo es más sostenible que fijarbreak: 90desde el primer día y bloquear todo el equipo con un objetivo inalcanzable a corto plazo.
Por qué esto importa más allá del número
Mutation testing es, en el fondo, la respuesta más objetiva disponible hoy a una pregunta que todo equipo debería hacerse sobre su propia suite: si mañana se introduce un bug real en este código, ¿lo detectaríamos, o el pipeline seguiría en verde? Es una pregunta que aplica exactamente igual a un test escrito a mano por un desarrollador con quince años de experiencia que a uno generado por un agente de IA en segundos, como los que describimos al hablar de generación de tests con IA y self-healing locators: el origen del test es irrelevante para esta técnica, lo único que evalúa es si detecta el fallo cuando el fallo existe.
Tampoco depende de en qué capa de tu estrategia de testing viva ese test. Ya sea que tu proyecto se organice según la pirámide clásica o según el testing trophy —algo que discutimos con detalle en otro artículo—, el mutation score se puede aplicar sobre unit tests, tests de integración o cualquier capa que ejecute código de producción real. La proporción entre capas decide dónde gastas el presupuesto de testing; el mutation score decide si lo que ya gastaste está sirviendo para algo.
La recomendación práctica: no lo introduzcas como un mandato del 100% desde el primer día, ni lo descartes por el coste de CI sin haberlo probado en modo incremental sobre un solo módulo crítico primero. La primera vez que un mutation score bajo en un módulo de facturación o de permisos te señale un hueco real de verificación que llevaba meses en producción sin que nadie lo supiera, va a quedar bastante claro por qué el coverage nunca fue suficiente por sí solo.
Artículos relacionados
Vitest vs Jest en 2026: por qué Vitest ganó la partida
Vitest ha desplazado a Jest como runner por defecto en la mayoría de proyectos nuevos. Analizamos las razones técnicas reales, no solo la novedad, y cuándo Jest sigue siendo la opción correcta.
La pirámide de testing en 2026: ¿sigue teniendo sentido?
Unit en la base, poco E2E arriba: el modelo lleva más de una década repitiéndose sin cuestionarlo. Con herramientas E2E mucho más rápidas que en 2009, revisamos si la proporción clásica sigue siendo la correcta.
Playwright de principio a fin: guía completa de testing E2E
Playwright se ha convertido en el estándar de facto del testing E2E. Esta guía va más allá de la instalación: selectores que no se rompen, fixtures reutilizables y arquitectura de suite mantenible.