Todos los tutoriales de mutation testing terminan en el mismo lugar: npx stryker run, sale un reporte, fin. El mío también.
En Mutation Testing: lo que aprendí implementándolo de verdad publiqué la instalación, el config completo y los tips de tuning. No los voy a repetir acá. Esta guía arranca en el minuto siguiente: tienes el reporte abierto y no sabes qué hacer con él.
Stryker instalado y corriendo sobre al menos un módulo, y unit tests que existan. Si no tienes ninguna de las dos cosas, la instalación paso a paso está acá y toma diez minutos. Vuelve después.
Los unit tests de esta guía casi siempre los escribe el equipo de desarrollo, y cada vez más con IA. Si eres QA, en los pasos 1 a 4 no escribes tests: corres Stryker, lees el reporte, señalas lo que sobrevivió y propones el gate. El paso 5 y la revisión del PR sí son tuyos de punta a punta.
Y empiezo por corregirme a mí misma, porque es el error que reproduce todo el mundo.
El stryker.config.mjs que publiqué en marzo tiene esta línea:
thresholds: { high: 80, low: 60, break: null },break: null es el valor por defecto de Stryker y significa el build no falla nunca, sin importar el score. Publiqué la técnica con el gate apagado. Funciona para aprender; no protege nada. El paso 4 de esta guía es exactamente esa línea, encendida.
Paso 1 — Quédate con dos números, no con uno
El reporte te da un mutation score. Te da otro más abajo y casi nadie lo mira, y es el que sirve para diagnosticar.
Las dos fórmulas, tal como las define Stryker:
Mutation score = detected / valid * 100Mutation score based on covered = detected / covered * 100Donde detected = killed + timeout, undetected = survived + no coverage, valid = detected + undetected y covered = detected + survived.
La diferencia está en una sola cosa: el score normal cuenta en tu contra los mutantes que ningún test toca; el de covered los ignora y solo juzga el código que tus tests sí ejecutan.
Compara los dos y tienes el diagnóstico gratis:
| Score | Covered | Qué te está diciendo |
|---|---|---|
| Bajo | Alto | Tus tests son buenos, pero hay código que no tocan. Te falta escribir tests. |
| Bajo | Bajo | Tus tests ejecutan el código y no verifican nada. Te sobran tests que no afirman. |
| Alto | Alto | La suite protege lo que cubre. Ahora la pregunta es si cubre lo que importa. |
Una suite generada por IA casi nunca falla en la primera fila. Falla en la segunda: ejecuta todo y no afirma casi nada.
Tiene sentido. Un agente que escribe tests toca todos los caminos —eso lo hace muy bien— y después escribe la assertion más segura que existe, la que no puede fallar.
Paso 2 — Los seis estados, y qué decides con cada uno
En el reporte cada mutante queda en uno de estos estados. Las definiciones son las de Stryker; la columna de la derecha es la decisión.
| Estado | Qué pasó | Qué haces |
|---|---|---|
| Killed | Al menos un test falló con el mutante activo. | Nada. Ese test funciona. |
| Survived | Todos los tests pasaron con el mutante activo. | Acá trabajas. Hay un test que ejecuta esa línea sin juzgarla. |
| No coverage | Ningún test cubre esa línea, y por eso sobrevivió. | Falta el test. Es otro problema, y más barato de explicar. |
| Timeout | Correr los tests con ese mutante colgó. | Cuenta como detectado. No lo persigas. |
| Compile error | El mutante no compila. | Ruido. El typescript-checker los filtra antes de correrlos. |
| Runtime error | Los tests reventaron en vez de fallar. | Ruido, pero míralo: a veces es un test frágil de verdad. |
Solo dos columnas importan: Survived y No coverage. Y se arreglan al revés — una escribiendo un test nuevo, la otra arreglando uno que ya existe y que te estaba mintiendo.
Paso 3 — De un mutante sobreviviente al test que lo mata
Este es el bucle. Es todo el oficio y se aprende con un ejemplo.
Tienes esta función:
export function puedeEditar(usuario: Usuario, recurso: Recurso): boolean { if (usuario.rol === "admin") return true; return recurso.autorId === usuario.id && !recurso.bloqueado;}Y este test, del tipo que produce un agente cuando le pides “cubre esta función”:
it("verifica permisos de edición", () => { const resultado = puedeEditar(usuarioAdmin, recurso); expect(resultado).toBeDefined(); expect(typeof resultado).toBe("boolean");});Pasa. Da coverage. Y el reporte te muestra tres mutantes sobrevivientes en esas dos líneas: && cambiado por ||, el ! eliminado, y el return true convertido en return false.
Léelo despacio: un mutante que invierte la regla de permisos entera sobrevive a ese test. La función podría devolver siempre true y el test seguiría verde.
El problema no es que falte cobertura. El problema es la assertion: toBeDefined() y typeof verifican que la función devolvió algo, no que devolvió lo correcto. Ese test no tiene oráculo.
El test que sí mata los tres mutantes:
it("el admin edita cualquier recurso", () => { expect(puedeEditar(usuarioAdmin, recursoAjeno)).toBe(true);});
it("el autor edita su recurso", () => { expect(puedeEditar(autor, recursoPropio)).toBe(true);});
it("el autor NO edita su recurso si está bloqueado", () => { expect(puedeEditar(autor, { ...recursoPropio, bloqueado: true })).toBe(false);});
it("nadie edita el recurso de otro", () => { expect(puedeEditar(otroUsuario, recursoPropio)).toBe(false);});Cuatro tests en vez de uno, y cada uno afirma un valor concreto. Vuelve a correr y los tres mutantes pasan a Killed.
Si esos unit tests los escribe el equipo de desarrollo, el bucle es el mismo con los roles repartidos. Tú señalas en la revisión del PR el mutante que sobrevivió: la línea, la mutación y el bug que dejaría pasar. El dev escribe el test que lo mata. Para detectar que falta un test no hace falta escribirlo.
La segunda fila roja, la del mock, merece una línea aparte. Cuando un test solo puede afirmar que se llamó a un mock, muchas veces el problema no está en el test sino en el código que prueba: la regla quedó pegada a una dependencia que nadie separó. Alejandro Lafourcade, arquitecto de software, lo explica desde el lado del diseño:
El problema no es cuántos mocks tenés. Es dónde están parados.
Él se queda en cómo se arregla el diseño. Desde QA, lo que te llevas es la señal: si al revisar un PR con tests generados ves más verificaciones de mocks que de valores, anótalo en la revisión como un tema de diseño y llévaselo al dev. Reescribir el test no lo resuelve.
Si al leer un test no puedes nombrar qué bug haría que ese test se ponga rojo, ese test no verifica nada. Es la misma pregunta que Stryker contesta con un número, hecha a mano en cinco segundos.
Cuando el sobreviviente no es tu culpa
Existen los mutantes equivalentes: mutaciones que no cambian el
comportamiento observable, así que ningún test podría matarlas. Aparecen como
Survived para siempre. No pelees con ellos: márcalos con // Stryker disable next-line y un comentario que diga por qué. Un disable sin razón escrita se
convierte en el próximo agujero.
Paso 4 — El gate en CI: la línea que faltaba
Este gate va en el pipeline del equipo y va a frenar sus PRs, así que se acuerda con ellos: no se instala a escondidas. Tu parte es llegar a esa conversación con el score real ya medido y la configuración lista para copiar.
Primero calibra. No pongas el umbral que te gustaría tener, pon el que tienes hoy:
npx stryker run --mutate "src/permisos/**/*.ts"Anota el score real. Ese número es tu break. A partir de ahí la suite solo puede mejorar: cualquier PR que la empeore rompe el build.
thresholds: { high: 80, low: 60, break: 64, // tu score real de hoy, no una aspiración},incremental: true,Cuando el score baja de break, Stryker termina con exit code 1 y el job falla. Eso es lo que convierte el reporte en un gate.
name: mutationon: pull_request
jobs: stryker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci
# el archivo incremental es lo que hace viable correr esto en cada PR - uses: actions/cache@v4 with: path: reports/stryker-incremental.json key: stryker-${{ github.ref }}-${{ github.sha }} restore-keys: stryker-${{ github.ref }}-
- run: npx stryker run
# el reporte HTML es la evidencia; súbelo aunque el job falle - uses: actions/upload-artifact@v4 if: always() with: name: reporte-mutantes path: reports/mutation/La primera corrida completa de un proyecto grande puede tardar horas y el
número inicial duele. Acota mutate a un módulo crítico, deja el gate ahí, y
suma módulos de a uno. Un gate chico que nadie desactiva vale más que uno
ambicioso que el equipo apaga en la segunda semana.
Paso 5 — Donde no puedes mutar: sabotaje dirigido
Mutation testing no aplica a E2E: mutar el código y levantar un browser por cada mutante es inviable. Y justamente ahí es donde más tests genera un agente.
El reemplazo es manual, tosco y funciona. El porqué está en el artículo; acá va el runbook:
- Elige cinco bugs por riesgo: los que te obligarían a escribir un incidente. No typos.
- Uno por rama descartable. Dos a la vez y no sabes cuál atrapó la suite.
- Corre la suite completa y anota el exit code, no la impresión.
- Revierte siempre. La rama se tira; esto no se mergea nunca.
- Anota qué test lo atrapó. Si ninguno, ya sabes qué test falta — con el bug en la mano, que es la mejor forma de escribirlo.
Copia esta tabla y llénala:
| Bug inyectado | ¿Suite roja? | Test que lo atrapó |
|---|---|---|
Cuando esté llena tienes un resultado reproducible en vez de una opinión. Eso es lo que se lleva a la reunión donde se decide si el release sale.
Revisar un PR con tests generados por IA, en cinco minutos
Sin herramientas, antes de aprobar:
- Lee las assertions primero, no los nombres. El nombre siempre suena bien; el nombre lo escribió el mismo modelo.
- Busca
toBeDefined,toBeTruthy,not.toThrowytoHaveBeenCalled. Ninguna afirma un valor. En un PR de veinte tests, es donde va a estar el problema. - Busca
skip,fixmeyonly. Untest.fixme()es un bug abierto disfrazado de suite verde — así fue como un agente me dejó un bypass de autenticación con exit code 0. - Rompe a propósito la función que el test dice cubrir. Si sigue verde, no estás aprobando un test: estás aprobando una línea que dice que hay un test.
- Si el PR toca un módulo con gate, mira el score del job. Ese es el único de los cinco que no depende de tu atención.
Cuando la IA escribía poco código, revisar tests a ojo alcanzaba. Ahora entran por lote y el ojo no escala. Un mutante sobreviviente no se cansa, no discute en el review y no le tiene simpatía a nadie: te dice qué línea de tu suite está de adorno, y te lo dice igual el lunes que el viernes a las siete.