Skip to content

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

  1. Bootstrap compartido test/helpers/bootstrap.ts: createTestApp() compone la app idéntica a producciónmain.ts y el bootstrap llaman ambos a configureApp() (src/common/configure-app.ts: prefix api/v1, cookie-parser, ValidationPipe con validation-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 en main.ts: dependen de env). El bootstrap mockea MailService capturando en mail.lastCode tanto el código OTP como el token de invitación. signupAdmin() encapsula signup → verify-email → onboarding.

  2. Bind único y explícito en IPv4: tras app.init(), el bootstrap hace server.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 con EADDRINUSE, no en silencio). Bonus: un bind por spec en vez de uno por request.

  3. Tipado de responses en tests: todo acceso a res.body va vía body<XxxDto>(res) (StandardResponse<T> + los response DTOs reales de src/), lo que permitió eliminar el override no-unsafe-* de test/** en eslint.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.ts agrega 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-pg 7.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).