Testear un proyecto legado desde cero con un agente de IA
La mayoría de ingenieros ya están usando IA en su trabajo de implementación diario, lo que convierte la pregunta "¿debería un agente ayudar a testear una base de código legada?" en una más interesante y que te va a ayudar mucho más: "¿en qué orden hay que testear?". Si le dices a un agente que aplique una estrategia de testing para una base de código sin tests y simplemente lo dejas ejecutándose, obtendrás un montón de tests de lo primero que encuentre y vas a tener un problema de cobertura aleatoria, de gasto excesivo de tokens, más una sensación de descontrol y desconfianza, que es justo lo contrario a lo que buscas. Además, testear con IA no hace que sea mejor, sino que lo podemos hacer más rápido, siendo la velocidad aquí una trampa: un agente rápido y sin secuenciar solo te podría llevar a una falsa sensación de seguridad. El orden importa más con un agente de por medio, no menos.
Un archivo de contexto es mejor que un agente a medida
No necesitas construir un agente personalizado para esto. Un agente de código general apuntando a un archivo de reglas sencillo, un CLAUDE.md o README.md en la raíz del repositorio, puede ser suficiente ayuda. Es tentador pasar la primera hora diseñando un agente especializado en lugar de describir el contexto que tenemos o queremos. Vamos a dedicar el esfuerzo a contextualizar, no a entender la base de código de quince años escrita por mil programadores distintos, que ahora el agente tiene que testear. Es importante cuándo se escribe este fichero de reglas: redactarlo antes de que el agente haya tocado el código significa adivinar convenciones que todavía no hemos verificado. Vamos a intentar evitar estos errores siguiendo la secuencia que describo a continuación.
Construye la infraestructura de tests antes de escribir un solo test
Empieza por la parte que no tiene nada que ver con el código legado en sí: una base de datos, fixtures1, y un comando que ejecute los tests. Para la mayoría de proyectos, una base de datos SQLite en memoria o en un archivo es suficiente, porque se puede resetear en milisegundos, sin contenedor que gestionar. El prompt en esta fase debería estar acotado con precisión: monta una base de datos SQLite ligera para tests end-to-end, añade un script de carga de fixtures, y conecta un comando npm run test:e2e, no toques todavía código de la aplicación.
Esa última restricción importa. Un agente que también está tocando código de la aplicación mientras construye infraestructura de tests está haciendo dos trabajos a la vez, y no vas a poder saber cuál de los dos introdujo un problema si algo se rompe. Mantén la infraestructura y el código de la aplicación como pasos separados, aunque la misma sesión acabe haciendo ambos.
Lo que sale de ese prompt es pequeño a propósito, una base de datos que se resetea en milisegundos y un cargador de fixtures, nada más:
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
export function createTestDatabase() {
const db = new Database(":memory:");
db.exec(readFileSync("schema.sql", "utf-8"));
return db;
}
export function loadFixtures(db: Database.Database) {
db.exec(readFileSync("test/fixtures/reservations.sql", "utf-8"));
}
Este código todavía no sabe nada del código legado. Es solamente infraestructura sobre la que se apoya el resto de la secuencia.
Prueba el pipeline en un endpoint sencillo
No apuntes al agente todavía a la parte más arriesgada del sistema. Busca el endpoint más simple que puedas encontrar: sin ramificaciones, sin concurrencia, nada interesante. Cualquier GET de un recurso sencillo sería suficiente. Una vez elegido, úsalo para comprobar que todo el pipeline funciona de verdad: las fixtures cargan, el test se ejecuta, el comando reporta el resultado correctamente. Este paso no va sobre el endpoint. Va sobre averiguar si los pasos 1 y 2 funcionan de verdad antes de que dependa de ellos algo real.
Pide al agente que reporte antes de escribir nada: lista los endpoints de esta base de código, ordenados de más simple a más complejo. No escribas tests todavía. Revisar esa lista tú mismo, antes de que exista una sola línea de código de test, sale más barato que revisar una suite de tests construida sobre una suposición equivocada de cuál era en realidad el endpoint más simple.
Una vez elegido uno, el siguiente prompt es deliberadamente estrecho: escribe un test end-to-end2 para este endpoint usando las fixtures que acabas de construir, ejecútalo, y confirma que pasa. Un test. Un endpoint. Confirma que toda la cadena funciona antes de pedir un segundo. Este paso también hace un segundo trabajo: es la primera vez que ves cómo se comporta el agente cuando un test falla y tiene que arreglar su propia fixture en lugar del código de la aplicación. Mejor notarlo ahora, en algo trivial, que descubrirlo por primera vez en el endpoint que de verdad importa.
Escribe el archivo de contexto después, no antes
Este es el paso que es fácil hacer al revés. Es tentador escribir el CLAUDE.md primero, documentando la arquitectura, las convenciones, el vocabulario de dominio, antes de que el agente toque nada. No lo hagas. Un archivo de reglas escrito basado en especulaciones, antes de haber leído de verdad el código, tiende a describir la base de código que uno asume que existe, no la que existe realmente, y se queda desactualizado en cuanto la realidad le lleva la contraria. Después de los pasos 1 a 3, el agente ha leído de verdad la capa de routing, el esquema de la base de datos, y la infraestructura de tests que acabáis de construir juntos. Sabe cosas de esta base de código que ninguno de los dos podríais haber escrito especulativamente el primer día.
Ahora es cuando el archivo de reglas se gana su sitio:
## Testing conventions
- End-to-end tests live in `test/e2e/`, one file per endpoint
- Fixtures reset before every test run — see `test/fixtures/reset.ts`
- Domain vocabulary: a "slot" is a bookable unit, not a time range
## Before writing code
- Report your plan first. Don't write tests until the plan is reviewed.
Esa última regla es la que hace el trabajo de verdad: es lo que convierte "analiza antes de programar" de algo que tienes que recordar pedir en algo que el agente hace por defecto.
Ahora apunta al agente a los endpoints que pueden entrar en conflicto
Con la infraestructura probada y el archivo de contexto anclado en lo que de verdad se ha aprendido, empieza la dificultad real: endpoints conflictivos, los que el artículo anterior argumentaba que se deberían testear primero a mano. Con un agente, siguen yendo los últimos en la secuencia, después de probar el pipeline, aunque sean los que cargan con el riesgo real.
Estos endpoints también pueden romper una suposición que el paso 1 hizo en silencio. Un test que pruebe una condición de carrera para una reserva doble del mismo hueco depende del bloqueo real de transacciones y del comportamiento de aislamiento entre conexiones simultáneas, justo lo que el modelo de bloqueo simplificado de SQLite no reproduce con fidelidad. El endpoint simple del paso 2 nunca expuso esa brecha, porque nada en él dependía de cómo maneja la base de datos a dos escritores a la vez. Al testear la condición de carrera, en cambio, esa brecha se expone de inmediato. Esa es la señal para cambiar, solo para estos tests, a una instancia en contenedor de la base de datos real que corre en producción, fixtures incluidos, en lugar de forzar que la opción rápida siga funcionando.
Este cambio no significa apuntar los tests contra la base de datos de desarrollo, ni contra producción. Es una base de datos que existe solo para el test run: se levanta en un contenedor con el mismo motor y versión que corre en producción (vía Testcontainers, o un servicio de docker compose dedicado a tests), se destruye al terminar, y nunca comparte estado con nada fuera de esa ejecución.
El primer paso al levantarla es correr las migraciones reales del proyecto contra ella, las mismas que se aplican en producción. Esto tiene un efecto colateral útil: de paso valida que las migraciones funcionan contra el motor real, algo que SQLite no puede comprobar por sí solo. Después se cargan los mismos fixtures que ya usaban los tests con SQLite, pero esta vez insertados contra un motor con foreign keys y constraints reales, lo que a veces saca a la luz suposiciones de los datos de prueba que SQLite dejaba pasar sin quejarse.
El reseteo entre tests es donde este enfoque se complica un poco más. El truco habitual para tests rápidos, envolver cada test en una transacción y hacer rollback al final, no sirve aquí: un test de conflicto necesita dos conexiones reales compitiendo por el mismo recurso, y eso exige que ambas hagan commit de verdad para que el bloqueo y el aislamiento se comporten como en producción. La transacción por test con rollback oculta justo el comportamiento que se está intentando probar. En su lugar, el reseteo se hace con un truncate de las tablas afectadas antes de cada test de conflicto, contra un único contenedor que se mantiene levantado durante toda la ejecución de la suite, no uno nuevo por test, porque levantar el contenedor sí es caro y hacerlo por test lo haría insostenible.
Es exactamente esta fricción (migraciones reales, fixtures con constraints reales, reseteo manual) la que justifica por qué se reserva solo para los endpoints que de verdad la necesitan. Para todo lo demás, SQLite sigue siendo la opción correcta.
El prompt aquí se mantiene deliberado, con la misma forma que en el paso 2 pero apuntando a material más difícil: propón un plan de test para el conflicto de doble reserva en este endpoint. No escribas el test todavía. Revisa el plan. Solo entonces pide el test en sí.
Un agente que escribe código antes de entender la base de código no te está ahorrando tiempo, está adelantando la revisión de código que vas a hacer de todas formas más tarde.
Conclusión
Un agente de IA no cambia lo que necesita una base de código legada sin tests: sigue necesitando infraestructura antes que tests, lo simple antes que lo arriesgado, y un archivo de contexto anclado en lo que es realmente cierto en lugar de en lo que parecía probable el primer día. Lo que cambia es lo fácil que resulta saltarse directamente a la parte interesante y acabar con un montón de cobertura que no protege nada real. La solución es la de siempre: hazlo por pasos, revisa el plan antes que el código, y deja que el agente llegue a los endpoints difíciles con suficiente contexto en lugar de empezar por ahí.
Notas
-
Fixtures de test: datos conocidos, cargados de antemano, contra los que corre un test, para que sus comprobaciones dependan de un estado inicial predecible y no de lo que sea que haya en la base de datos. ↩
-
Test end-to-end: un test que lanza una petición a través del sistema real, tal como lo haría un cliente, por HTTP, a través del handler real, hasta la base de datos real, y comprueba la respuesta. ↩
Artículos relacionados
Recibe nuevos artículos en tu correo
Escritura sobre arquitectura pragmática, sistemas mantenibles y trade-offs reales de ingeniería. Publicado cuando está listo.