Apariencia
0040. Tests e2e: bootstrap compartido y bind explícito en IPv4 loopback
Estado
Aceptada
Contexto
Los 14 specs e2e del backend duplicaban idéntico el setup (createTestingModule + mock de MailService + prefix + ValidationPipe + signup/onboarding, ~500 líneas) y accedían a res.body sin tipar, lo que obligaba a mantener un override eslint que apagaba las reglas no-unsafe-* para todo test/**.
Además la suite sufría una flakiness intermitente (403/404 aleatorios que "se mudaban" de spec entre corridas, en ráfagas de hasta 6 fallos). El diagnóstico (2026-07-14, con instrumentación en cliente y servidor) demostró que era ambiental, no del backend: supertest, cuando recibe un server que no está escuchando, lo liga y lo cierra en un puerto efímero nuevo por cada request con bind wildcard :: (IPv6). Que un puerto esté libre en IPv6 no garantiza que lo esté en IPv4: si el número asignado coincidía con un puerto 127.0.0.1 ya ocupado por otro proceso de la máquina (IDE, Postman Agent, tooling de Dart — verificado con lsof), la request del test a http://127.0.0.1:PUERTO la respondía ese proceso (el bind IPv4 específico gana sobre el wildcard IPv6): 404/403 pelados sin content-type, bodies vacíos. Demostración: server propio ligado en :::60437 (puerto que el IDE tenía en IPv4) → GET por 127.0.0.1 devolvía el 404 del IDE; por [::1], nuestra respuesta.
Decisión
Bootstrap compartido
test/helpers/bootstrap.ts:createTestApp()compone la app idéntica a producción —main.tsy el bootstrap llaman ambos aconfigureApp()(src/common/configure-app.ts: prefixapi/v1,cookie-parser,ValidationPipeconvalidation-pipe.options.ts,ResponseInterceptor), de modo que un middleware global nuevo entra a producción y a los e2e por el mismo punto (CORS y Swagger quedan enmain.ts: dependen de env). El bootstrap mockeaMailServicecapturando enmail.lastCodetanto el código OTP como el token de invitación.signupAdmin()encapsula signup → verify-email → onboarding.Bind único y explícito en IPv4: tras
app.init(), el bootstrap haceserver.listen(0, '127.0.0.1')una sola vez. supertest detecta el server ya escuchando y lo reutiliza sin re-ligar → la colisión IPv4/IPv6 se vuelve imposible (el kernel solo asigna un puerto libre en IPv4 loopback, y si estuviera tomado fallaría ruidosamente conEADDRINUSE, no en silencio). Bonus: un bind por spec en vez de uno por request.Tipado de responses en tests: todo acceso a
res.bodyva víabody<XxxDto>(res)(StandardResponse<T>+ los response DTOs reales desrc/), lo que permitió eliminar el overrideno-unsafe-*detest/**eneslint.config.mjs— el lint type-aware aplica completo también en tests.
Consecuencias
- Specs ~40 líneas más cortos cada uno (diff neto de la tarea: −407 líneas) y un solo lugar para evolucionar el setup (p. ej. si
main.tsagrega middleware global). - La flakiness quedó eliminada de raíz; si un spec e2e vuelve a recibir respuestas "imposibles" (status sin body JSON, 404 en rutas existentes), sospechar primero de un bypass del bootstrap (un spec ligando por su cuenta) antes que del backend.
- El cast
body<T>()es tipado estático, no validación runtime: si un DTO cambia de forma, el test compila igual — las aserciones siguen siendo la validación real. - El warning
DeprecationWarning: Calling client.query()...que aparece en la suite es un bug cosmético conocido de@prisma/adapter-pg7.8.0 (prisma/prisma#29646); se descartó como causa de la flakiness y desaparecerá al actualizar el adapter cuando el fix upstream se publique.
Referencias
- Tarea
e2e-helpers-tipado(2026-07-14) y evidencia del diagnóstico en_planning/_evidencia/del monorepo. - ADR-0039 — base de test aislada (complementaria: aquella aísla los datos; esta, la red).