Calidad sin Humo
Blog / Artículo

Mutation Testing: lo que aprendí implementándolo de verdad

2026.03.23
QA que piensa
15629 Palabras
- Visitas
- Comentarios
Mutation Testing: lo que aprendí implementándolo de verdad

En un artículo anterior hablé sobre por qué el coverage es la métrica más mentirosa en QA.

Ese artículo dejó una pregunta sin responder:

Si el coverage no garantiza calidad… ¿cómo sé si mis tests realmente detectan bugs?

La respuesta tiene nombre: Mutation Testing.

Y cuando la implementé de verdad — no en teoría, sino en un proyecto real con código en producción — tuve que replantear todo lo que creía saber sobre la calidad de mi suite.

Esta es la historia de cómo pasé de 0 unit tests a 92, encontré 3 bugs que pasaron code review, y descubrí por qué mis 44 tests E2E no eran suficientes.


¿Qué es Mutation Testing?

Es una técnica que evalúa la calidad de tus tests introduciéndole bugs artificiales al código fuente. A esos bugs se les llama mutantes.

Si tu test no detecta un bug artificial, tampoco va a detectar uno real.

El coverage te dice cuántas líneas de código ejecutan tus tests. Mutation testing te dice si esos tests realmente verifican algo.

Son dos preguntas completamente distintas.


¿Cómo funciona?

Código fuente Introduce mutante Corre unit tests ¿Falló? Killed o Survived

Dos posibles resultados por cada mutante:

Mutante eliminado (killed) — tu test falló como debía. Exactamente lo que quieres.
Mutante sobrevivió (survived) — tu test no detectó el cambio. Punto débil en tu suite.

La métrica que importa se llama Mutation Score:

Mutation Score = (Mutantes eliminados / Total de mutantes) × 100

Un score del 80% significa que tus tests detectaron el 80% de los bugs artificiales. El 20% restante pasó sin que nadie lo notara.


La confusión que yo misma cometí

Voy a ser honesta: cuando descubrí mutation testing, lo primero que pensé fue “perfecto, lo conecto a mis tests de Playwright y listo.”

Tenía un proyecto con 44 tests E2E en Playwright. Dashboard en verde. Coverage decente. Estaba tranquila.

Pero cuando intenté implementar mutation testing, me di cuenta de algo fundamental:

No funciona con tests E2E.

No es una limitación menor ni un bug por resolver. Es un problema arquitectónico:

  • Mutation testing muta el código fuente y necesita que los tests lo importen directamente (import { fn } from './utils')
  • Los tests E2E corren en un navegador contra un servidor separado — nunca ven el código mutado
  • Incluso si pudieras hacerlo funcionar, 500 mutantes × 5 minutos por suite = 41 horas de ejecución
  • Los mantenedores de Stryker (la herramienta principal de JS/TS) lo dicen explícitamente: mutation testing está diseñado para unit tests, no para E2E
Cuidado con información falsa

@stryker-mutator/playwright-runner no existe. Nunca existió. No hay paquete en npm, no hay código en el repo de Stryker. Si lo ves mencionado en algún blog o tutorial, es información incorrecta.

Los runners reales de Stryker son: Jest, Vitest, Mocha, Karma, Jasmine, Cucumber y Tap. Todos para tests que importan código directamente — no para E2E.


Qué tuve que hacer: de 0 a 92 unit tests

Mi proyecto tenía 44 tests E2E y exactamente cero unit tests.

Eso significaba que mutation testing era literalmente imposible. Necesitaba tests que importaran las funciones del código fuente y validaran su comportamiento directamente.

Elegí un archivo de utilidades — 369 líneas de código, 18 funciones — que era el corazón de la lógica de negocio del portal. Funciones que formateaban datos, validaban estados, calculaban permisos.

Escribí 92 unit tests con Jest. No fue rápido. Pero cada test que escribía era una pregunta concreta: “¿qué pasa si esta función recibe un array vacío?”, “¿qué devuelve si el usuario no tiene permisos?”, “¿qué ocurre en el caso límite?”

Cuando terminé, configuré Stryker con el jest-runner y corrí el análisis.


Los resultados

248 Mutantes
236 Killed
95.16% Mutation Score
3 Bugs reales

Pero lo más valioso no fue el número. Fue lo que encontré en el proceso.


3 bugs reales en código de producción

Mutation testing no solo evaluó mis tests — expuso defectos en el código fuente que habían pasado code review y estaban en producción.

Bug 1: Condición duplicada

Lo que estaba en producción
if (!users || !users) {
return [];
}

La segunda condición es idéntica a la primera. Probablemente la intención era !users || !users.length (validar que el array no esté vacío). El código funcionaba porque JavaScript evalúa ![] como false, pero la validación estaba incompleta — un array vacío pasaba el if y podía causar errores más adelante.

Un mutante cambió || por && y mi test no falló. Eso me llevó a leer el código y descubrir la condición duplicada.

Bug 2: Código muerto — inicialización que se sobreescribe

Lo que estaba en producción
let result = getDefaultConfig(); // línea 360
// ... nada en medio ...
result = buildConfigFromData(data); // línea 364

La primera asignación no sirve para nada. Se sobreescribe inmediatamente. Stryker eliminó la línea 360 y todos los tests siguieron pasando — porque esa línea nunca tuvo efecto real.

Código muerto que nadie notó. No causa bugs visibles, pero es deuda técnica que oscurece la intención del código.

Bug 3: Optional chaining incompleto

Lo que estaba en producción
const imagePath = images[0]?.path;

El optional chaining protege contra images[0] siendo undefined (si el array tiene elementos pero el primero es undefined). Pero no protege contra un array vacíoimages[0] en un array vacío devuelve undefined, y luego ?.path maneja eso. Hasta ahí bien.

El problema real es que el código asumía que images siempre era un array. Nunca validaba si images era null o undefined. Un mutante que eliminaba la línea completa sobrevivió porque ningún test probaba el caso de images = null.

Lo que descubrí

Cuando agregué ese test, descubrí que la función lanzaba un TypeError en producción si el backend devolvía null en vez de un array vacío.


E2E + Mutation Testing = dos capas complementarias

Después de esta experiencia, entendí que no es uno u otro. Son dos capas que protegen cosas diferentes:

Tests E2E (Playwright)Unit Tests + Mutation
Qué protegenFlujos de usuario completosLógica interna de cada función
Qué detectanPantalla rota, flujo interrumpido, integración fallidaCondición mal escrita, caso límite sin manejar, código muerto
Ejemplo”El usuario no puede completar una compra""formatPrice(0) devuelve '$NaN' en vez de '$0.00'
VelocidadMinutos (depende del servidor)Segundos (corre en memoria)
FlakinessMedia-alta (red, browser, servidor)Baja (determinístico)
Mutation testingNo aplicaAplica directamente

Mis 44 tests E2E validaban que el portal funcionaba de punta a punta. Pero ninguno detectaba una condición duplicada en una función de utilidad. Para eso necesitas unit tests — y para saber si esos unit tests son buenos, necesitas mutation testing.

El flujo que ahora recomiendo

commit → unit tests → mutation testing → E2E → deploy


Ejemplo concreto

Si todavía no está claro por qué mutation testing requiere unit tests, este ejemplo lo explica:

utils/withdrawal.ts
export function canWithdraw(balance: number, amount: number): boolean {
return balance > amount;
}

Un unit test:

utils/__tests__/withdrawal.test.ts
it("permite retiro cuando el saldo es suficiente", () => {
expect(canWithdraw(100, 50)).toBe(true);
});

El test pasa. Coverage al 100%. Pero Stryker introduce este mutante:

Mutante: cambia > por >=
return balance >= amount;

Tu test sigue pasando. canWithdraw(100, 50) sigue devolviendo true.

El mutante sobrevivió. Eso significa que si un developer cambia > por >= por accidente, tus tests no lo detectan. Y ese cambio tiene consecuencias reales: un usuario con exactamente $100 podría retirar $100 cuando no debería.

El test que elimina el mutante:

El test que faltaba
it("no permite retiro cuando el saldo es exactamente igual al monto", () => {
expect(canWithdraw(100, 100)).toBe(false);
});
¿Por qué no funciona con E2E?

Este test importa canWithdraw directamente. Si estuvieras testeando esto con Playwright haciendo click en un botón de “Retirar” en un navegador, Stryker no podría inyectar el mutante porque el código corre en el servidor, no en el proceso del test.


Herramientas por ecosistema

EcosistemaHerramientaRunners soportados
JavaScript / TypeScriptStryker (v9.6.0)Jest, Vitest, Mocha, Karma, Jasmine, Cucumber, Tap
JavaPITest (v1.22.0)JUnit 5, TestNG
Pythonmutmut (v3.5.0)pytest
C#Stryker.NET (v4.14.0)MSTest, NUnit, xUnit
Gogomu go test

Punto importante: ninguna de estas herramientas soporta E2E tests. Todas requieren tests que importen el código fuente directamente.


Cómo empezar — paso a paso con Stryker + Jest

Instalación

Terminal window
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner @stryker-mutator/typescript-checker
Ver configuración completa de stryker.config.mjs
stryker.config.mjs
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
testRunner: "jest",
jest: {
projectType: "custom",
configFile: "jest.config.ts",
enableFindRelatedTests: true,
},
mutate: [
"src/**/*.ts?(x)",
"!src/**/*.test.ts?(x)",
"!src/**/*.spec.ts?(x)",
"!src/**/__tests__/**",
"!src/**/*.d.ts",
],
checkers: ["typescript"],
tsconfigFile: "tsconfig.json",
coverageAnalysis: "perTest",
ignoreStatic: true,
reporters: ["html", "clear-text", "progress"],
thresholds: {
high: 80,
low: 60,
break: null,
},
incremental: true,
concurrency: 4,
};

Ejecutar

Terminal window
npx stryker run

Tips de configuración

  • checkers: ['typescript'] — Filtra mutantes que rompen el tipado antes de ejecutarlos. Ahorra tiempo.
  • ignoreStatic: true — Ignora mutantes en código estático (que se ejecuta al cargar el módulo). Reduce ruido.
  • incremental: true — Cachea resultados entre corridas. La primera vez tarda; las siguientes solo analiza lo que cambió.
  • enableFindRelatedTests: true — Solo corre los tests que importan el archivo mutado. Crítico para performance.
  • concurrency: 4 — Ajusta según tu CPU. Más workers = más rápido pero más memoria.

¿Qué Mutation Score es aceptable?

RangoInterpretación
Menos del 60%Suite con gaps importantes. Los tests existen pero no verifican bien.
Entre 60% y 80%Nivel razonable para proyectos en crecimiento.
Entre 80% y 90%Suite robusta. Buena cobertura de casos límite.
Más del 90%Excelente. Reservado para código crítico de negocio.

Para referencia: Google usa mutation testing en 1,000+ proyectos internos. Sentry lo corre semanalmente en sus SDKs de JavaScript y reporta scores de ~62% en sus mejores paquetes. En mi caso, logré 95.16% en un archivo crítico de lógica de negocio, pero eso requirió escribir 92 tests muy específicos.

El número que debes perseguir depende del riesgo del módulo. Un flujo de pagos merece un score distinto que un componente de UI estático.


Limitaciones — cuándo no aplicarlo

  • Tiempo de ejecución. Por cada mutante se corren los tests relacionados. En proyectos grandes puede significar horas en la primera corrida. Usa el modo incremental para que las siguientes sean rápidas.

  • Requiere unit tests. Si tu proyecto solo tiene tests E2E, necesitas escribir unit tests primero. Mutation testing no funciona sin ellos.

  • No aplica a todo el código. No tiene sentido mutar configuración, migraciones de base de datos o integraciones externas.

  • No detecta lógica faltante. Si nunca escribiste el test para un escenario, mutation testing no puede inventarlo. Solo evalúa lo que ya existe.

  • Mutantes equivalentes. Algunas mutaciones no cambian el comportamiento real del código. Aparecen como “survived” pero no representan un problema. Hay que revisarlos manualmente.


¿Y ahora qué?

El coverage te dice que tus tests ejecutaron el código. Mutation testing te dice si tus tests entienden el código.

Yo tenía 44 tests E2E y cero unit tests. Pensaba que estaba cubierta. Mutation testing me demostró que no — y de paso encontró 3 bugs reales que habían pasado code review.

Si ya trabajaste tu coverage y quieres saber si tu suite realmente te protege, mutation testing es el siguiente paso lógico. Pero necesitas unit tests para usarlo. Si no los tienes, ese es tu verdadero primer paso.

No hace falta aplicarlo a todo el proyecto de una vez. Empieza por el módulo más crítico de tu sistema — el que, si falla en producción, duele más.

Eso es shift-left testing en serio: no solo testear antes, sino testear mejor.

Volver a artículos
Fin del artículo