Ilustración abstracta de bloques geométricos verdes con varias piezas alteradas en rojo que atraviesan una malla de comprobación, sobre fondo oscuro

Mutation testing: ¿los tests de tu agente detectan algo?


El mutation testing mide si tus tests detectan fallos, no si ejecutan líneas. La herramienta introduce pequeños cambios en el código (un > por un >=, un - por un +) y vuelve a pasar la suite. Si los tests siguen en verde con el código roto, ese test no protege nada. Es la forma más directa de auditar los tests que escribe tu agente.

Por qué el verde no basta cuando el agente escribe los tests

Un test que pasa solo demuestra que no falla, no que compruebe algo. Cuando el mismo agente escribe el código y sus tests, tiene todos los incentivos para cerrar el bucle en cuanto la suite sale verde. La guía de buenas prácticas de Claude Code lo dice sin rodeos: Claude se detiene cuando el trabajo "parece hecho", y por eso recomienda darle un check ejecutable. El problema es que ese check puede ser tan débil como el test que lo sostiene.

Los patrones de test débil que conviene buscar en el diff de un agente son estos:

  • Asserts de forma, no de valor: assert resultado is not None o comprobar solo el tipo.
  • Mocks del propio código bajo prueba: el test verifica que el mock devuelve lo que se le dijo que devolviera.
  • Tautologías: el test recalcula el resultado esperado con la misma fórmula que la implementación.
  • Snapshots aceptados sin leer: el primer snapshot se da por bueno aunque ya contenga el bug.

La cobertura de líneas no detecta ninguno de los cuatro, porque todas esas líneas se ejecutan. El mutation testing sí, porque rompe el código y mira si alguien se queja.

Qué es un mutante y qué significa cada estado

Un mutante es una copia de tu código con un único cambio pequeño; lo que importa es si algún test falla con él activo. Si al menos uno falla, el mutante está "muerto" y ese comportamiento está protegido. Si todos pasan, "sobrevive" y tienes un hueco. Stryker define la puntuación como mutantes detectados entre mutantes válidos por 100.

Estado (mutmut / Stryker)Qué significaQué haces
Killed / KilledAlgún test falló con el mutante activoNada, ese cambio está cubierto
Survived / SurvivedTodos los tests pasaron con el código rotoRevisar: test débil o mutante equivalente
No tests / No coverageNingún test ejecuta esa líneaFalta un test entero, no un assert
Timeout / TimeoutEl mutante provocó un bucle o una espera largaNormalmente cuenta como detectado; revisa si es sospechoso
Suspicious / Runtime errorResultado anómalo o error en lugar de fallo de testRevisa el entorno antes de sacar conclusiones

Los estados de mutmut aparecen en su README oficial y los de Stryker en su página de estados y métricas.

Paso 1: acota la mutación a lo que ha tocado el agente

No mutes el repositorio entero: muta solo los ficheros de producción que el agente ha añadido o modificado. El coste del mutation testing crece con el número de mutantes y con lo que tarda tu suite, porque se ejecutan tests por cada mutante. Acotarlo al diff lo mantiene en minutos y además centra la revisión en lo nuevo.

Este comando lista los ficheros Python añadidos o modificados en tu rama respecto a main, excluyendo los tests:

git diff --name-only --diff-filter=AM main...HEAD -- 'src/*.py' ':!tests/*'

En los pathspecs de git, el * por defecto también cruza directorios, así que src/*.py incluye subcarpetas. Si tu rama base no se llama main, cámbiala.

Paso 2: mutmut en proyectos Python

mutmut es la opción directa para Python con pytest; en PyPI, la versión más reciente es la 3.8.0, publicada el 12/09/2026. Un requisito que conviene saber antes de instalar: necesita soporte de fork, así que funciona en Linux y macOS, y en Windows solo a través de WSL.

Supongamos que el agente ha escrito esta función y este test. El test pasa, pero no comprueba ningún valor:

def aplicar_descuento(precio: float, porcentaje: int) -> float:
    if porcentaje > 50:
        raise ValueError("descuento máximo 50 %")
    return round(precio * (1 - porcentaje / 100), 2)


def test_aplicar_descuento():
    resultado = aplicar_descuento(100, 10)
    assert resultado is not None
    assert isinstance(resultado, float)

La configuración va en pyproject.toml. Estas claves son las que documenta mutmut 3; only_mutate limita la mutación a los ficheros que salieron del paso 1:

[tool.mutmut]
source_paths = ["src/"]
pytest_add_cli_args_test_selection = ["tests/"]
only_mutate = ["src/tienda/descuentos.py"]

Después lanzas la ejecución y abres el navegador interactivo de resultados:

pip install mutmut
mutmut run
mutmut browse

Con el test de arriba, lo esperado es que sobrevivan mutantes como cambiar > por >= o alterar la resta de la fórmula, porque ningún assert mira el número. Dentro de mutmut browse, según la documentación, pulsas f para volver a probar la función o m para el módulo después de mejorar los tests. Los resultados se guardan en el directorio mutants/; bórralo si quieres empezar desde cero.

Paso 3: Stryker en proyectos JavaScript o TypeScript

En JS y TS la herramienta de referencia es StrykerJS, y su punto fuerte para este flujo es que acepta ficheros y rangos de líneas por CLI. La guía de inicio usa npm init stryker@latest, que instala Stryker, te pregunta por tu test runner y genera un stryker.config.mjs.

Este bloque inicializa Stryker y luego muta solo los ficheros TypeScript del diff, pasándolos a --mutate separados por comas:

npm init stryker@latest

FILES=$(git diff --name-only --diff-filter=AM main...HEAD -- 'src/*.ts' ':!*.test.ts' | paste -sd, -)
npx stryker run --mutate "$FILES"

La referencia de configuración documenta también rangos como src/app.js:5-10, útiles si el agente solo tocó una función de un fichero grande. Para ejecuciones repetidas existe el modo incremental (--incremental), que guarda resultados en reports/stryker-incremental.json y solo vuelve a probar lo que cambió. Ojo: no detecta cambios en dependencias, variables de entorno o ficheros .snap; en ese caso usa --force.

Si quieres que la puntuación rompa el build, configura thresholds.break. Por defecto vale null y los umbrales visuales son high: 80 y low: 60, según la misma referencia. El valor 60 de este ejemplo es una heurística de partida, no un estándar:

/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
  testRunner: 'jest',
  incremental: true,
  thresholds: { high: 80, low: 60, break: 60 },
};

El runner jest requiere tener instalado su plugin, algo que el inicializador hace por ti si lo eliges; si usas otro runner, cambia ese valor por el que te propuso npm init stryker@latest.

Paso 4: devuelve los supervivientes al agente, con reglas

Los mutantes supervivientes son el mejor contexto que puedes darle al agente: concretos, reproducibles y con un criterio de éxito binario. En lugar de pedir "mejora los tests", le pasas el cambio exacto que ningún test detectó. La propia guía de Claude Code sugiere separar papeles, con una sesión que escribe tests y otra que escribe el código, y pedir tests que eviten mocks cuando no hacen falta.

Esta plantilla funciona igual en Claude Code, Codex, Cursor o Copilot. Rellena los campos con la salida de mutmut browse o del informe HTML de Stryker:

Contexto: estos mutantes sobreviven en [fichero]:
[pega aquí el diff de cada mutante superviviente]

Tarea: escribe o corrige tests en [ruta de tests] para que cada mutante falle.

Reglas:
- No modifiques código de producción.
- No añadas "# pragma: no mutate" ni "// Stryker disable".
- No uses mocks del módulo bajo prueba.
- Cada assert compara un valor concreto, no solo tipo o existencia.
- Si crees que un mutante es equivalente (no cambia el comportamiento),
  no lo tapes: explícame por qué y déjalo sin test.

Verificación: ejecuta [mutmut run / npx stryker run --mutate ...]
y enséñame qué mutantes quedan vivos.

La regla de las anotaciones importa porque ambas herramientas permiten desactivar mutantes con comentarios (# pragma: no mutate en mutmut, // Stryker disable next-line all en Stryker). Un agente que busca el verde puede usarlas para silenciar lo que no sabe cubrir. Este comando las detecta en el diff antes de aceptar el cambio:

git diff main...HEAD | grep -nE '^\+.*(pragma: no mutate|Stryker disable)'

Resultado esperado y cómo leer la puntuación

El objetivo no es el 100 %, sino que no sobreviva ningún mutante que represente un bug que te importaría en producción. En el ejemplo de aplicar_descuento, un test mejorado comprobaría aplicar_descuento(100, 10) == 90.0, aplicar_descuento(100, 50) == 50.0 y que 51 lanza ValueError. Con esos tres casos, lo esperado es que mueran tanto el cambio de fórmula como el de > a >=, porque el caso límite de 50 dejaría de devolver un valor.

Algunos supervivientes no merecen un test:

  • Mutantes equivalentes: el cambio no altera el comportamiento observable. Ningún test puede matarlos.
  • Literales de mensajes y logs: si el texto exacto del error no forma parte de tu contrato, dejarlo vivo es razonable.
  • Código defensivo imposible de alcanzar: si no puedes construir la entrada, cuestiona el código antes que el test.

Como referencia práctica, no como dato: revisa cada superviviente uno a uno la primera vez. Si la mayoría resultan equivalentes o triviales, ajusta do_not_mutate_patterns en mutmut o los mutadores de Stryker en vez de pedirle al agente que los "arregle".

Checklist antes de aceptar tests escritos por un agente

Esta lista resume el flujo completo y cabe en la descripción de un PR o en el CLAUDE.md/AGENTS.md del repo:

  1. La suite pasa en local con el código original.
  2. Has ejecutado mutation testing solo sobre los ficheros de producción del diff.
  3. No quedan mutantes "No tests" / "No coverage" en código nuevo.
  4. Cada superviviente está matado o justificado por escrito como equivalente o irrelevante.
  5. El diff no añade pragma: no mutate ni Stryker disable sin motivo explicado.
  6. El agente no ha modificado código de producción para matar mutantes (compruébalo con git diff --stat).
  7. Los asserts comparan valores concretos, incluidos los casos límite de cada condición.

Cuándo no merece la pena este flujo

El mutation testing aporta más en lógica de negocio con condiciones y cálculos, y poco en código de pegamento. Antes de montarlo, descarta estos casos:

  • Suites lentas o con dependencias externas: si cada ejecución tarda minutos o toca bases de datos reales, multiplicarla por cada mutante no es viable sin antes aislar esos tests.
  • Configuración, wiring y plantillas: mutar un fichero que solo conecta dependencias genera ruido y pocos hallazgos útiles.
  • Prototipos que vas a tirar: si el código no llegará a producción, un test de humo basta.
  • Windows sin WSL con mutmut: el requisito de fork lo impide; usa WSL o una imagen de contenedor.
  • Librerías que no toleran fork: la documentación de mutmut recomienda process_isolation = "forkserver" con gevent, grpc o torch, a cambio de más lentitud.

Y una limitación de fondo: el mutation testing te dice si los tests detectan cambios en el código que existe, no si falta código. Si el agente olvidó un requisito entero, ningún mutante lo revelará; para eso sigue haciendo falta leer el diff contra la especificación.

Preguntas frecuentes

¿Puedo dejar que el agente ejecute mutmut o Stryker él solo?

Sí, siempre que tenga permiso para ejecutar esos comandos y le pidas que te enseñe la salida en vez de resumirla. Mantén la regla de no tocar código de producción ni añadir anotaciones de desactivación, y revisa el diff final con el comando de grep anterior.

¿Lo meto en CI en cada PR?

Solo si lo acotas a los ficheros cambiados y la duración es aceptable. En Stryker, --mutate con los ficheros del diff más thresholds.break te da un fallo con código de salida 1 cuando la puntuación baja del umbral. Si tarda demasiado, empieza ejecutándolo en local o en un job nocturno.

¿Sustituye a la cobertura de código?

No, la complementa. La cobertura te dice qué líneas no ejecuta ningún test y es barata de calcular; el mutation testing te dice cuáles de las líneas ejecutadas no están realmente verificadas. Usa la cobertura para encontrar huecos grandes y la mutación para auditar la calidad de lo que ya está cubierto.

Compartir X LinkedIn