Mutation testing en JavaScript: audita los tests de tu IA
Le pides al agente que escriba los tests del módulo de envíos. Todo en verde. La cobertura marca 100 % en líneas, ramas y funciones.
Haces merge.
Tres semanas después alguien cambia un >= por un > en la regla de envío gratis. Los pedidos de 50 € justos empiezan a pagar envío. Los tests siguen en verde y la cobertura, en 100 %.
Nadie mintió. El agente escribió tests que ejecutan cada línea. Lo que no hizo fue comprobar que esas líneas hacen lo correcto. La cobertura no tiene forma de enterarse. El mutation testing en JavaScript, sí.
En corto: la cobertura de código te dice qué líneas se ejecutaron durante los tests, no si algún test fallaría al romperlas. El mutation testing en JavaScript, con StrykerJS, introduce bugs pequeños en tu código y comprueba si algún test los detecta: el que sobrevive marca un test que no protegía nada. Es la forma más barata de auditar los tests que escribe un agente sin tener que leerlos todos.
¿Qué es el mutation testing?
El mutation testing es una técnica que modifica el código de producción en pequeños puntos (cada cambio es un "mutante") y vuelve a pasar los tests para ver si alguno falla: si fallan, el mutante muere; si todo sigue en verde, el mutante sobrevive y has encontrado comportamiento que ningún test verifica.
Los mutantes son bugs plausibles, no aleatorios. Los operadores de Stryker cambian a >= b por a > b, a + b por a - b, true por false, vacían un bloque o sustituyen una condición entera por true. Son los errores pequeños que se cuelan en un refactor sin que nadie los vea.
El resultado se resume en el mutation score: mutantes detectados entre mutantes válidos, por cien. La documentación de métricas lo define así y separa los estados que verás en el informe:
- Killed: al menos un test falló con el mutante activo.
- Survived: todos los tests pasaron con el mutante activo.
- No coverage: ningún test ejecuta ese código. No llega ni a probarse y cuenta como no detectado.
- Timeout: el mutante provocó un bucle o una espera infinita. Cuenta como detectado.
Por qué los tests generados por IA pasan y no prueban nada
Los tests generados por IA fallan en lo mismo que los de un junior con prisa: ejecutan el código, pero sus asserts no comprometen nada. toBeDefined(), toBeTruthy(), toBeGreaterThanOrEqual(0), o un mock que se configura y luego se comprueba a sí mismo.
El agente optimiza lo que le pides. Si le pides "tests que pasen" y "cobertura alta", te da las dos cosas. Ninguna de las dos garantiza que el test falle cuando el código está mal.
Lo vimos desde otro ángulo en cómo blindar los tests para que Claude Code no los toque: proteger los tests del agente sirve de poco si los tests ya eran débiles cuando entraron.
| Cobertura de código | Mutation score | |
|---|---|---|
| Qué mide | Líneas, ramas y funciones ejecutadas por algún test | Porcentaje de bugs sembrados que algún test detecta |
| Qué no detecta | Asserts débiles, tests sin expect útil, límites sin probar |
Mutantes equivalentes (cambios que no alteran el comportamiento) |
| Coste | Una ejecución de la suite con instrumentación | Una ejecución de tests por cada mutante cubierto |
| Herramienta en JS/TS | @vitest/coverage-v8, Istanbul, Jest --coverage |
StrykerJS (@stryker-mutator/core + runner) |
| Limitación / riesgo | Da falsa seguridad: 100 % compatible con tests que no fallan nunca | Lento en suites grandes y empuja a escribir tests para matar mutantes irrelevantes |
Tutorial de mutation testing con StrykerJS y Vitest en TypeScript
Partimos de una función de seis líneas. El ejemplo está ejecutado con @stryker-mutator/core 10.0.0 y Vitest 4.1.11 (octubre de 2026). Más abajo explico por qué no Vitest 5.
// src/envio.ts
export function calcularEnvio(subtotal: number, esPremium: boolean): number {
if (esPremium || subtotal >= 50) {
return 0;
}
return 4.99;
}
Y este es el test "típico de agente". Pasa, y vitest run --coverage devuelve 100 % en statements, branches, functions y lines:
// src/envio.spec.ts
import { describe, it, expect } from 39;vitest39;;
import { calcularEnvio } from 39;./envio39;;
describe(39;calcularEnvio39;, () => {
it(39;devuelve un coste de envío39;, () => {
expect(calcularEnvio(20, false)).toBeDefined();
});
it(39;aplica envío gratis a partir de cierto importe39;, () => {
expect(calcularEnvio(100, false)).toBe(0);
});
it(39;gestiona clientes premium39;, () => {
expect(calcularEnvio(10, true)).toBeGreaterThanOrEqual(0);
});
});
1. Instalar Stryker
La vía oficial es el asistente, que pregunta por tu runner y genera la config (getting started):
npm init stryker@latest
Si prefieres hacerlo a mano, instala el core y el runner. El plugin no incluye Vitest: usa el que ya tienes en el proyecto.
npm i -D @stryker-mutator/core @stryker-mutator/vitest-runner
# Con Jest: npm i -D @stryker-mutator/core @stryker-mutator/jest-runner
2. Configuración mínima
Stryker busca stryker.config.{json,js,mjs,cjs} en la raíz (config file):
// stryker.config.mjs
// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
testRunner: 39;vitest39;, // con Jest: 'jest'
mutate: [39;src/**/*.ts39;, 39;!src/**/*.spec.ts39;],
reporters: [39;clear-text39;, 39;progress39;, 39;html39;],
thresholds: { high: 80, low: 60, break: 70 },
};
export default config;
Con Vitest, el runner ignora coverageAnalysis y usa siempre perTest: cada mutante solo ejecuta los tests que lo cubren. Es lo que lo hace viable.
3. Ejecutar y leer el informe
npx stryker run
La consola muestra cada superviviente con su diff, y el informe HTML queda en reports/mutation/mutation.html. Resultado con el test del agente: 8 mutantes, 6 killed, 2 survived, mutation score 75 %. Con 100 % de cobertura.
Los dos supervivientes:
- if (esPremium || subtotal >= 50) {
+ if (esPremium || subtotal > 50) {
- if (esPremium || subtotal >= 50) {
+ if (true) {
El primero es el bug de la apertura: nadie prueba el límite de 50. El segundo es peor: si el envío fuera siempre gratis, ningún test se enteraría, porque toBeDefined() acepta un 0 igual que un 4,99.
4. El test que mata a los dos
describe(39;calcularEnvio39;, () => {
it(39;cobra 4,99 por debajo de 5039;, () => {
expect(calcularEnvio(49.99, false)).toBe(4.99);
});
it(39;el envío es gratis justo en 5039;, () => {
expect(calcularEnvio(50, false)).toBe(0);
});
it(39;un cliente premium no paga envío aunque el pedido sea pequeño39;, () => {
expect(calcularEnvio(10, true)).toBe(0);
});
});
Mismo número de tests. Mutation score: 100 %, 8 de 8 killed. La diferencia es que cada assert afirma un valor concreto en un punto que importa. Es la misma disciplina que en TDD potenciado por IA: el test define el comportamiento, no lo acompaña.
Cómo meter mutation testing en el flujo con agentes
Lanzar Stryker sobre todo el repo en cada cambio no escala. Lo que funciona es acotar.
Muta solo lo que cambió. --mutate acepta una lista de ficheros separada por comas, e incluso rangos de líneas (src/envio.ts:3-5). En una PR:
CHANGED=$(git diff --name-only --diff-filter=d origin/main...HEAD -- 39;src/*.ts39; | { grep -v 39;\.spec\.ts$39; || true; } | paste -sd, -)
if [ -n "$CHANGED" ]; then npx stryker run --mutate "$CHANGED"; fi
En GitHub Actions, haz el checkout con fetch-depth: 0; si no, origin/main no existe en el runner.
Activa el modo incremental. Con --incremental, Stryker guarda resultados en reports/stryker-incremental.json y reutiliza los de mutantes cuyo código y tests no cambiaron (incremental). En la segunda ejecución del ejemplo el log dice "8 of 8 mutant result(s) are reused".
Ojo: solo detecta cambios en ficheros mutados y de test; si actualizas dependencias, pasa --force.
Pon un suelo en CI. thresholds.break hace que Stryker salga con código 1 si el score queda por debajo. Por defecto es null y nunca rompe el build (configuración). Empieza bajo y súbelo.
Dale los supervivientes al agente. Aquí el mutation testing deja de ser una métrica y se vuelve un bucle de feedback:
Stryker ha dejado estos mutantes vivos en src/envio.ts (diff abajo).
Escribe o corrige tests que los maten. Reglas:
- No toques el código de producción.
- Cada assert compara contra un valor concreto, nunca toBeDefined/toBeTruthy.
- Si un mutante no cambia el comportamiento observable, dímelo y no escribas test para él.
La última regla importa más de lo que parece, y la explico abajo. Si quieres el sistema entero de verificación de código generado, sin leer cada línea, está en verificar código generado por IA sin leerlo todo, y lo trabajamos con proyectos reales en el workshop SDD + Agentic Engineering. Si estás empezando a delegar trabajo en agentes, el ebook gratuito El Developer Agéntico es el punto de partida.
Cuándo NO usar mutation testing
Lo recomiendo para módulos con lógica de negocio. No para todo.
Es lento en suites grandes. Cada mutante es una ejecución de tests. En este comentario de Hacker News, un usuario de Stryker lo cuantifica: "a small-ish TS project where our normal test suite runs in 2 minutes or so takes about 20-25 with Stryker". Sobre el repo completo en cada push, no; sobre el diff, sí.
Los mutantes equivalentes son ruido. Hay mutaciones que no cambian el comportamiento observable (por ejemplo, alterar un límite que el resto del código ya filtra). Sobreviven siempre y ningún test sensato las mata. Perseguir el 100 % te obliga a revisarlas a mano.
Perseguir mutantes deforma los tests. Uncle Bob lo usó como rol de hardening en su harness y, como contamos en harness multiagente vs un solo agente, encontró bugs reales, pero el algoritmo empujaba al agente a hacer cosas tontas con tal de matar mutantes. Un agente con la orden "sube el score" acopla tests a detalles de implementación. Por eso el prompt de arriba le deja decir "este no".
El runner también falla. Con Vitest 5, @stryker-mutator/vitest-runner 10.0.0 ejecuta cero tests por mutante y casi todo sale como Survived.
Está documentado en el issue #6210 de stryker-js, abierto el 4 de septiembre de 2026: el autor reporta un score que cae de 47,36 con Vitest 4.1.11 a 2,96 con Vitest 5.0.0. El ejemplo de este post lo reproduce: 0 % con Vitest 5.0.3, 75 % con 4.1.11.
A 10 de octubre de 2026 los PR del arreglo (#6214 y #6220) siguen abiertos. Si ves "Ran 0.00 tests per mutant", no te fíes del score.
Por dónde empezar hoy
Elige el módulo con más lógica de negocio que tu agente haya testeado en el último mes y hazle mutation testing en JavaScript con Stryker. Precios, permisos, cálculos de fechas. Solo ese fichero.
Si sale por encima del 80 %, tus tests probablemente valen. Si sale al 40 % con cobertura del 95 %, acabas de medir cuánto te estaban diciendo los tests del agente.
Los mutantes que sobreviven suelen apuntar a casos límite que nadie escribió. Para encontrarlos sin pensarlos uno a uno, el property-based testing con fast-check genera cientos de entradas por test.
Hay errores que conviene que no lleguen ni a los tests: pasar un orderId donde va un userId. Esos los cierran los branded types en TypeScript.
En testing en Angular con IA ya lo mencionábamos de pasada. Si trabajas en Angular, el curso de Testing en Angular con Jest y Testing Library cubre cómo escribir asserts que afirman comportamiento.
Y si quieres montar el flujo completo con agentes, de la spec al test que mata mutantes, está en Construye con IA.
Preguntas frecuentes
¿Qué diferencia hay entre cobertura de código y mutation score?
La cobertura mide qué código se ejecuta durante los tests. El mutation score mide qué porcentaje de bugs introducidos a propósito detectan esos tests. Puedes tener 100 % de cobertura y 0 % de mutation score si ningún assert compara contra un valor concreto.
¿Stryker funciona con Jest y con Vitest?
Sí. Para Jest instala @stryker-mutator/jest-runner y pon testRunner: 'jest'; para Vitest, @stryker-mutator/vitest-runner y testRunner: 'vitest'. En octubre de 2026 el runner de Vitest tiene un bug abierto con Vitest 5 (issue #6210), así que mantén Vitest 4 para mutation testing hasta que salga el arreglo.
¿Qué mutation score es bueno?
Los umbrales por defecto de Stryker marcan 80 como alto y 60 como bajo. No hay una cifra universal: en lógica de negocio crítica apunta por encima de 80; en código de pegamento o UI, el esfuerzo de subirlo rara vez compensa.
¿Cómo hago que el mutation testing no tarde una eternidad?
Muta solo los ficheros cambiados con --mutate, activa --incremental para reutilizar resultados entre ejecuciones y deja que el runner use análisis de cobertura perTest, que ejecuta solo los tests que tocan cada mutante.
¿Puedo pedirle al agente de IA que mate los mutantes supervivientes?
Sí, y es el mejor uso. Pásale el diff de cada superviviente y prohíbele tocar el código de producción y usar asserts débiles. Déjale marcar mutantes equivalentes en vez de forzar tests absurdos para matarlos.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
