Un test en verde me dice que el test pasa, no que proteja algo. Rompiendo controles a propósito he encontrado pruebas que habrían seguido en verde con el control destrozado. Aquí cuento cómo lo hago, qué encontré y dónde mi práctica sigue siendo manual o solo informa.
Mutación manual en agente-telegram
En agente-telegram, un asistente por Telegram con agentes separados en zonas de confianza, los controles críticos pasan por el mismo ciclo (ADR-009):
- Escribo la prueba.
- Rompo el control a mano: comento la comprobación o invierto la condición.
- Compruebo que alguna prueba se pone en rojo.
- Restauro el control.
El registro vive en E4 §3.3: 82 filas con fecha, y casi todas anotan la mutación aplicada y qué pruebas cayeron. Es mutación manual, sin herramienta generando mutantes, y el propio ADR lo asume como coste: es lento y no se automatiza del todo. A cambio no persigo un porcentaje de cobertura (en ese proyecto ni lo mido).
Lo valioso son los fallos de mi validación
Las filas en verde no son lo importante. Lo son las veces que la mutación no ponía nada en rojo, o lo ponía por el motivo equivocado.
Mutaciones que no tumbaron lo que debían
- Puerta de acceso. Quité la validación de
autorizadodel constructor y no cayó nada: los casos malos (None,0, una cadena) los frenaba la validación del id entrante. El que no frenaba nadie eraautorizado=True: en Python1 == True, así que entraba cualquiera con el id 1. Añadítest_un_autorizado_booleano_no_deja_entrar_al_usuario_1. - Registro sin contenido. Colé el texto del usuario como metadato de
registrar_evento. Tampoco cayó nada: el filtro descarta valores con espacios y todos mis mensajes de prueba los tenían. Un mensaje de una sola palabra, lo que mandas al pasar una contraseña a secas, se escribía entero. - Una prueba sin casos. Estaba parametrizada con la misma constante que mi mutación reducía, así que generó un solo caso y pasó; cayó un solo rojo, y no el que tocaba. Ahora escribo las puertas a mano.
- La plataforma tapando el fallo. Mutar los permisos de una carpeta no enrojecía nada en Windows: las pruebas del modo real son de POSIX y allí se saltan.
Casi nunca es que el control sobrara. Es una segunda capa tapando el agujero, o una prueba mirando al lado equivocado.
Rojo, pero por el motivo equivocado
El caso más incómodo lo encontré yo. Mutar el control de esquemas permitidos, cambiando la lista blanca por una lista negra, puso 7 pruebas en rojo y lo di por validado. Al repetirlo vi que 5 de esos 7 rojos eran un KeyError: la función reventaba antes de decidir nada. Además la prueba aceptaba dos excepciones distintas, así que no distinguía "rechazado por el esquema" de "rechazado por otra cosa". Lo arreglé exigiendo el tipo de excepción exacto y derivando la lista blanca del mapa de puertos. Añadí entonces una regla al ciclo del ADR: comprobar que cae la prueba que esperaba y que cae por el motivo que esperaba.
mutmut en reto
En reto sí uso herramienta: mutmut sobre cuatro módulos críticos (guardián anti-SSRF, cerrojo, cifrado en reposo y documentos), 1.213 líneas de las 11.205 de src/. Configuración real:
paths_to_mutate = "src/red_segura.py,src/cerrojo.py,src/storage.py,src/documentos.py"
runner = "python -m pytest -x -q --assert=plain tests/test_red_segura.py tests/test_redirecciones.py tests/test_cerrojo.py tests/test_storage.py tests/test_clave_cifrado.py tests/test_documentos.py"La invocación llevaba escrita en el CI sin haberse ejecutado nunca. La primera ejecución falló, y al arreglarla fueron saliendo cosas:
- La opción no existía.
--paths-to-mutatedesapareció en mutmut 3.x, y mirequirements-dev.txtfijaba la 3.7.0. La 3.7 además revienta con mis importsfrom src..., así que volví a la 2.5.1 en vez de renombrar el paquete para conseguir una métrica. - El runner por defecto. Lanza la batería entera por cada mutante: unos 830 casos, 55 s por pasada y unos 300 mutantes son más de cuatro horas, y GitHub corta a las seis horas. Acoté el runner a los tests de cada módulo y la pasada baja a menos de 2 s.
La primera medición, sobre tres de los cuatro módulos (los tests de documentos.py bajan un modelo y no había red), dio 167 mutantes, 100 muertos y 67 vivos. Casi todos los vivos son ruido, porque mutmut 2.x mutila cada cadena de texto. Uno de ellos no lo era, y aún no he revisado cuántos de los 67 son reales:
- return True # si no se puede interpretar, se trata como peligrosa
+ return FalseEs _es_interna() en el guardián anti-SSRF. Invertida, los 38 tests de su fichero seguían en verde. No era explotable por el camino público, pero el otro llamador le pasa lo que devuelve el DNS. Era código defensivo que nadie defendía, y salió tras unos 830 tests en verde y un 79,86 % de cobertura de rama. Lo cerré con un test nuevo, validado rompiendo el control.
En el CI ese job es informativo, no bloqueante: solo se lanza a mano y ejecuta mutmut run || true. Con tanto ruido, ponerlo en rojo lo haría ignorable. No tengo aún un umbral y no voy a inventarlo.
Que la documentación no mienta
La cobertura de reto la mido, pero como suelo mínimo: umbral de 79 sobre el 79,86 % de rama, que va detrás de la medición y nunca delante. Y un script de trazabilidad pone el CI en rojo si un test apunta a un requisito que no existe, si un requisito crítico se queda sin test o si un conteo de tests declarado en la documentación no cuadra con lo que pytest recoge. El ancla vive en el test:
@pytest.mark.requisito("RF-11")
def test_un_desconocido_no_llega_al_llm(): ...Nació de una auditoría propia: mi E4 declaraba 784 casos y había 815, y nada lo detectaba.
Un arnés que no da veredicto a medias
En agente-telegram, python arnes.py lanza toda la seguridad de golpe. Una revisión con otro modelo me encontró que pytest devuelve 0 con --help, --collect-only o un -k, sin ejecutar la batería, y yo imprimía el cartel verde igual. Ahora el veredicto solo existe sin argumentos:
hay_veredicto = not argumentosY un arnés sin pruebas recogidas es rojo:
if resultado.returncode == PYTEST_SIN_PRUEBAS:
print("=" * 70)
print(" ARNÉS EN ROJO: no se recogió NINGUNA prueba.")
print(" Un arnés vacío no está en verde: está sin comprobar.")
print("=" * 70)
return 1test_arnes_no_da_verdes_falsos.py fija lo primero con seis pruebas contra una carpeta de juguete. Con hay_veredicto = True siempre caen cinco, y la del uso normal sigue verde. Un límite: no vi una prueba propia de la rama de cero pruebas.
Lo que ningún test ve
El ADR-010 dice que no hago TDD sobre la salida de un modelo: acabaría escribiendo pruebas decorativas. Ahí pruebo en real. Al comparar mi tokenizador con el oficial y medirlo en la VPS con el modelo real, vi que rompía la memoria semántica: dos frases sin relación daban 0,92 de parecido, y una prueba daba por buena la conducta rota. Y probando /olvido, un comando que no existe, el modelo contestó que había borrado mi información, sin borrar nada.
En multi-agente, el testeo en vivo con LLM real (34 sesiones sobre una copia aislada) halló un bypass de la guarda anti-RCE con 1.288 tests en verde de partida (23/08): file_writer escribía en tests/, la guarda deja ejecutar scripts de tests/ si no son recientes y, con un directorio, ni mira la antigüedad, y pytest sobre un directorio importa conftest.py. Lo reproduje con una prueba de concepto inofensiva antes (marcador creado: True) y después del arreglo falla (False).
Cómo lo verifico hoy
| Pieza | Cómo la verifico |
|---|---|
| Mutación en agente-telegram | 82 controles, cada uno con su fila y su fecha |
| mutmut en reto | Job sobre los 4 módulos críticos |
| Claves en el historial (reto) | gitleaks, recogido en mi auditoría del 04/09 (segunda pasada el 06/09) |
Control 82, /tools | Validado en el arnés y probado en la VPS el 22/09 |
Un control sin su mutación es una promesa, y una mutación en la que no miras por qué cae el test es decoración.