Una estrategia de testing para una base de código sin tests
Cuando nos piden empezar a utilizar tests en un proyecto donde no existe ningún test aún, el instinto es abrir un archivo y ponerse a escribir tests unitarios de las nuevas funcionalidades. Desde mi punto de vista, es un punto de partida bastante equivocado: acabaremos teniendo tests de features aleatorias, y no creo que sea lo que necesita un sistema legado sin ninguna cobertura. Lo que necesita es establecer límites en los que se pueda confiar, y la forma más rápida de construir esos límites es empezar a buscar dónde el sistema es más frágil a nivel de negocio: pagos, reservas, creación de recursos...
Empieza por los endpoints que pueden entrar en conflicto
No todos los endpoints merecen la misma primera mirada. El endpoint con más tráfico del sistema no es necesariamente el más peligroso. El que merece la pena testear primero es aquel en el que pueden pasar dos cosas a la vez y solo una de ellas puede ganar: dos peticiones reservando el mismo hueco, dos workers cogiendo el mismo job, un saldo descontado dos veces porque un retry saltó mientras la primera petición todavía estaba en curso.
A estos les llamaremos endpoints de conflicto: código donde el correcto funcionamiento depende del orden, de un estado que puede cambiar entre la lectura y la escritura, o incluso código que no tenemos muy claro qué hace, pero que alguien no documentó y escribió, y preferimos dejarlo así porque sabemos que funciona. Normalmente son fáciles de encontrar sin leer mucho código: pregúntate qué haría que un cliente te escribiera un email a ti personalmente si se rompiera. Esa lista de urls es el punto de partida real, no "lo que sea más fácil de testear", ni "lo nuevo", ya que empezamos ahora.
Por qué los primeros tests son end-to-end
Un test end-to-end1 lanza una petición a través del sistema real, tal como lo haría un cliente — por HTTP, a través del routing y el handler reales, hasta la base de datos real — y comprueba la respuesta, no ninguna función interna. Sin mockear las partes en las que todavía no confías, porque no conoces su forma lo suficientemente bien como para mockearlas con honestidad.
Por eso es exactamente la herramienta correcta para empezar. No necesitas entender cómo está estructurado el handler legado por dentro para escribir uno; solo necesitas saber qué se supone que garantiza desde fuera. Para un endpoint de reservas, la garantía es fácil de enunciar y fácil de violar:
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../app";
describe("POST /reservations", () => {
it("lets only one of two concurrent requests for the same slot succeed", async () => {
const payload = { roomId: "12A", date: "2026-08-04" };
const [first, second] = await Promise.all([
request(app).post("/reservations").send(payload),
request(app).post("/reservations").send(payload),
]);
const succeeded = [first, second].filter((response) => response.status === 201);
expect(succeeded).toHaveLength(1);
});
});
Este test no le importa si el handler son 300 líneas enredadas, o un pipeline limpio. Solo le importa el contrato: la entrada y la salida. Manda dos peticiones en conflicto y obtén exactamente un éxito. Eso es lo que lo hace seguro de escribir el primer día, contra código en el que todavía no confías.
Es cierto que estos tests tienen un coste y un mantenimiento. Los tests end-to-end son, además, lentos porque no están testeando tu código de forma aislada — están testeando un servidor HTTP real hablando con una base de datos real, lo que significa que cada ejecución paga el arranque del proceso, las idas y vueltas de red, y cambia el estado de la base de datos. En cuanto a lentitud, este coste se dispara rápido si envolvemos cada endpoint "por si acaso". Y sobre su mantenimiento, estos tests pueden fallar por razones que no tienen nada que ver con el comportamiento que se supone que protegen: una migración renombra una columna, un fixture se desactualiza en silencio, una base de datos de test compartida queda en un estado que la siguiente ejecución no esperaba. Nada de eso es una regresión en el código — es deuda de mantenimiento en los propios tests, y se acumula exactamente igual que la deuda del código legado que estás intentando arreglar.
Por todo esto los flujos que elijamos importan también. Una vez que seamos capaces de testear endpoints con facilidad, hay que resistirse a la tentación de envolver cada endpoint solo porque el mecanismo ya existe — el objetivo no es cobertura, es un número pequeño de tests en los que sigas confiando dentro de seis meses. Estructura cada uno alrededor de un único escenario, con un nombre que enuncie lo que debe pasar (así también puede funcionarnos como documentación), no el mecanismo ("solo una de dos peticiones concurrentes tiene éxito", no "test del endpoint de reservas"). Dale a cada test sus propios fixtures en lugar de compartir estado con el resto de tests — un test que depende de datos que dejó otro test es un test que falla por el motivo equivocado, y depurar ese fallo desperdicia justo la confianza que el test debía construir. Revisa el conjunto periódicamente, además: si un endpoint de conflicto se reescribe y su riesgo se traslada a otro sitio, el test end-to-end que lo cubría debería moverse con él, no quedarse como un fósil que nadie recuerda haber escrito. Un montón de tests end-to-end frágiles es peor que ninguno; un conjunto pequeño y deliberadamente mantenido es una red de seguridad que de verdad puedes conservar — sometida al mismo estándar que el código de producción que tiene al lado.
La red es lo que te permite refactorizar
En cuanto ese test end-to-end existe y pasa, algo cambia: el código entre la petición y la respuesta deja de ser intocable. No necesitas entender cada línea del flujo que hay dentro del código legado antes de cambiarlo — necesitas que el test end-to-end siga pasando después de cambiar y refactorizar dicho código. Ese es todo el sentido de la prueba. Te permite desenredar la maraña pieza a pieza: separar lógica — si todo se ejecuta en una función gigantesca, sacar la comprobación de conflicto a su propia función, sacar la lógica de precios a otra — o introducir nuevos servicios allí donde el código esté haciendo más de una cosa, para mantener responsabilidades simples.
El test end-to-end no se cambia en todo este proceso. Esta capa exterior sigue comprobando que con la misma entrada, se genera la misma salida, mientras vamos remodelando el interior — que es exactamente lo que hace que se pueda sobrevivir a refactorizar un código legado y no algo insufrible y aterrador.
Dónde entran los tests unitarios
Aquí es donde los tests unitarios se ganan su sitio, y donde hacen un trabajo genuinamente distinto al del test end-to-end. El test end-to-end verifica un contrato desde fuera: dada esta petición, espera esta respuesta, sin importar cómo funcione por dentro. Un test unitario verifica un contrato mucho más pequeño, de forma aislada, para una única pieza que acabamos de extraer — independiente de la infraestructura, o de "la red" como la hemos llamado antes:
import { describe, it, expect } from "vitest";
import { assertSlotIsFree } from "../reservations/slot-availability";
describe("assertSlotIsFree", () => {
it("rejects a slot that already has a confirmed reservation", () => {
const existing = [{ roomId: "12A", date: "2026-08-04", status: "confirmed" }];
expect(() =>
assertSlotIsFree(existing, { roomId: "12A", date: "2026-08-04" })
).toThrow("Slot already reserved");
});
});
assertSlotIsFree no sabe nada de HTTP, de routing ni de la base de datos. Conoce una sola regla, y el test unitario fija esa regla de forma independiente de todo lo que la rodea. Esa es la diferencia real entre los dos tipos de test: el test end-to-end protege el comportamiento del que depende el negocio; el test unitario protege la decisión a la que acabas de ponerle nombre. Ninguno sustituye al otro. El test end-to-end te dice que la extracción no rompió nada observable; el test unitario te dice que la pieza extraída es correcta en sus propios términos, y seguirá diciéndotelo mucho después de que el código que la rodea haya vuelto a cambiar de forma.
El primer test en una base de código legada debería proteger una promesa que el sistema le hace a alguien. Cada test después de ese debería proteger una promesa que una pieza de código le hace al resto del sistema.
Repite esa extracción las veces suficientes — saca una regla, ponle nombre, dale un test unitario, confirma que el test end-to-end sigue pasando. El código legacy no se reescribe de una sola vez: es una tarea que lleva tiempo. Se reemplaza un contrato cada vez, con una red debajo durante todo el camino.
Conclusión
Una estrategia de testing para una base de código sin nada no es un objetivo de cobertura — es una secuencia. Encuentra los endpoints donde el sistema puede tener conflictos, envuélvelos en un conjunto pequeño y deliberadamente mantenido de tests end-to-end, y usa la seguridad que eso te da para descomponer el código de debajo. Cada pieza que extraes se lleva su propio test unitario y su propio contrato, independiente de la capa HTTP que antes era lo único que verificaba que existiera. Así es como un sistema legado se convierte en uno testeado: no testeando todo a la vez, sino testeando lo único que de verdad dolería equivocar, y dejando que eso sea el punto de apoyo para todo lo que viene después.
Notas
-
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.