Este proyecto tiene 854 pruebas automáticas repartidas en 15 niveles distintos. Ninguno sobra: cada uno responde una pregunta que los demás no pueden responder. Esta página recorre todos, con los números reales del repositorio y las decisiones de ingeniería que hay detrás de cada uno.
Se puede leer en dos profundidades: los bloques de arriba dan el mapa completo en unos diez minutos; las secciones de decisiones y anatomía entran en el detalle.
809
Tests unitarios
46 archivos, 188 suites
45
Tests e2e
6 specs con navegador real
59.8%
Cobertura
líneas de src/lib/**
43.5%
Mutation score
mutantes detectados
15
Niveles de prueba
14 activos, 1 pendiente
OK
Último deploy
6306346 en main
Cifras de la última corrida en main, leídas de la tabla ci_runs que alimenta el propio pipeline. · ver el run en GitHub Actions
La pirámide de pruebas, con los números de este proyecto
La forma de la pirámide no es decorativa: cada estrato está dibujado en proporción al número real de tests que tiene. Muchas pruebas baratas abajo, pocas y caras arriba. Cuando se invierte —muchos e2e y pocas unitarias— la suite se vuelve lenta, frágil y nadie la corre.
End-to-end45 tests· minutos
Un navegador real recorre el flujo completo. Caros, lentos, pocos.
Contratos5 tests· segundos
La forma de la respuesta de la API queda congelada por un esquema Zod.
Integración98 tests· segundos
Base de datos real y desechable: transacciones, UNIQUE y concurrencia de verdad.
Unitarias696 tests· milisegundos
Lógica pura, sin BD ni red. Baratas: por eso son la mayoría.
Los otros 11 niveles no van en la pirámide, a propósito. SAST, accesibilidad, chaos, monitoreo, carga y usabilidad no verifican comportamiento: verifican propiedades del sistema (¿es seguro?, ¿es usable?, ¿aguanta?, ¿sigue vivo?). Meterlos en la pirámide daría a entender que son «más tests», y llevaría a compararlos por volumen cuando lo que importa de ellos es la cobertura de riesgo, no la cantidad.
El mapa del pipeline
Seis etapas, desde el portátil hasta las tres de la madrugada del día siguiente. Cada una es clicable: qué corre exactamente, cuánto tarda, qué la dispara y qué pasa si falla. Abajo se puede simular una corrida completa con tres desenlaces distintos — el tercero, el rollback automático, es el que mejor explica por qué la verificación no termina en el merge.
Local — Antes de que nadie más lo vea
Bloquea el deploy
Lo disparaA mano, mientras escribo código
Duración~4 s la suite completa
Vive enpackage.json
npm test — los 799 tests de Vitest
npm run test:e2e:ui — Playwright en modo inspector, si toqué una página
npx astro check — type-check de todo el proyecto
Si falla: No hay push. Es la única etapa donde el coste de un fallo es cero.
Push — git push origin main
No bloquea
Lo disparaUn commit en main o la apertura de un PR
Duracióninstantáneo
Vive en.github/workflows/
GitHub Actions arranca 4 workflows en paralelo: CI, Security, Accessibility y (los domingos) Mutation
Vercel arranca su propio build por la integración git, sin esperar a los tests
Si falla: —
CI — Test + build + e2e
Bloquea el deploy
Lo disparapush y pull_request
Duración~3-6 min
Vive en.github/workflows/ci.yml
Job quality: vitest run --coverage y luego npm run build
Job e2e: instala Chromium, siembra dos bases libSQL desechables y corre los 45 tests de Playwright
Extrae métricas (cobertura, tests pasados/fallidos) del reporte JSON para publicarlas
En paralelo: npm audit, CodeQL y axe-core, todos con continue-on-error
Si falla: El job queda en rojo y el PR no se puede fusionar. Los scanners son la excepción: registran, no bloquean.
Deploy — Vercel publica la versión nueva
Bloquea el deploy
Lo disparaIntegración git de Vercel
Duración~1-3 min
Vive enastro.config.mjs
Build de Astro con el adaptador de Vercel
La versión nueva pasa a servir codebymike.tech
/api/health empieza a devolver el SHA del commit recién desplegado
Si falla: El deploy anterior sigue sirviendo. Vercel no promueve un build que no compila.
Verificación — La prueba que corre en producción
Bloquea el deploy
Lo disparaSolo en push a main
Duraciónhasta 8 min de espera + ~15 s de checks
Vive en.github/workflows/ci.yml
Sondea /api/health cada 10 s hasta que el SHA coincida con el del commit (máx 8 min)
Tres health checks seguidos; se exigen al menos 2 con HTTP 200
Si no pasa: npx vercel rollback revierte a la versión anterior
Notifica el rollback a ntfy con prioridad 5 y reporta el run al panel LAB
Si falla: Rollback automático y push al teléfono. El job termina en rojo, pero el sitio ya volvió a estar sano.
Operación — Las 24 horas siguientes
No bloquea
Lo disparacron-job.org, cada ~5 min, para siempre
Duracióncontinuo
Vive ensrc/pages/api/cron/
9 monitores sondean endpoints públicos y alimentan /status
Rollups de seguridad y detección de anomalías del micro-SIEM
Error budget de los SLO: cuánto margen de caída queda este mes
Cualquier incidente dispara una notificación push
Si falla: Se abre un incidente, se registra en el histórico y sale una alerta. Nada de esto depende de que yo esté mirando.
Simular una corrida
Elige un desenlace y mira cómo recorre el pipeline etapa por etapa.
El camino feliz: el código llega a producción y se queda.
Local799 tests en verde. Push.
PushCI, Security y Accessibility arrancan en paralelo.
CIVitest 799/799 · build OK · 45 e2e en verde.
DeployVercel publica. /api/health ya devuelve el SHA nuevo.
Verificación3 de 3 health checks con HTTP 200.
OperaciónLos monitores siguen en verde. Run reportado al panel LAB.
Desenlace: La versión nueva se queda. Tiempo total desde el push: unos 10 minutos, sin intervención humana.
El caso más común. Nada llega a producción.
LocalCon prisa, no corrí la suite antes del push.
PushCI arranca.
CIpayments.test.ts: «doble clic: requests concurrentes con la misma clave crean UN pago» — falla.
DeployEl PR no se puede fusionar. En main, el build de Vercel puede publicar, pero el job queda en rojo y la verificación lo atrapa.
VerificaciónNo se llega.
OperaciónProducción sigue sirviendo la versión anterior.
Desenlace: Coste del fallo: un job de CI de 4 minutos. Ningún usuario vio nada. Esto es exactamente para lo que existen los tests.
Pasa todos los tests y aun así rompe producción. El escenario que nadie enseña.
Local799 tests en verde. Todo correcto.
PushCI arranca.
CISuite completa en verde, build OK, e2e en verde.
DeployVercel publica la versión nueva en codebymike.tech.
VerificaciónHealth check: 0 de 3 con HTTP 200. Falta una variable de entorno que en local sí existía.
Operaciónnpx vercel rollback revierte · push a ntfy con prioridad 5 · run registrado como rolled_back.
Desenlace: El sitio vuelve solo a la versión anterior en menos de un minuto, y me entero por el teléfono. Ningún test unitario podía haber detectado esto: la diferencia estaba en el entorno, no en el código.
Anatomía de un test
Un test real del repositorio, disecado en capas. Es el patrón que siguen los 809: arrange (preparar), act (ejecutar), assert (afirmar). Conmuta las pestañas para ver qué líneas corresponden a cada capa.
tests/payments.test.ts
it('doble clic: requests concurrentes con la misma clave crean UN pago')
1// BD libsql en archivo temporal: las transacciones abren otra
2// conexión y ':memory:' no comparte tablas entre ellas.
3vi.mock('../src/db', async () => {
4 const file = join(tmpdir(), `payments-test-${process.pid}.db`)
5 const client = createClient({ url: `file:${file}` })
6 return { db: drizzle(client, { schema }), __client: client }
7})
8 9it('doble clic: requests concurrentes con la misma clave crean UN pago', async () => {
10 const key = `race-${crypto.randomUUID()}`
11 const [a, b] = await Promise.all([
12 createPaymentIdempotent(checkoutInput(key)),
13 createPaymentIdempotent(checkoutInput(key)),
14 ])
15 expect(a.payment.id).toBe(b.payment.id)
16})
Arrange
Preparar un mundo desechable
Se sustituye el módulo de base de datos por uno que apunta a un archivo temporal único por proceso. Nunca se toca Turso: los tests escriben, y escribir en la base real gastaría cuota y contaminaría datos de clientes. El nombre lleva el PID para que dos corridas en paralelo no se pisen.
Act
Reproducir el doble clic
`Promise.all` con dos llamadas idénticas y la misma clave de idempotencia. No es una simulación de concurrencia: son dos operaciones realmente simultáneas contra la misma base, compitiendo por el mismo índice UNIQUE. Es la única forma de ejercer la condición de carrera de verdad.
Assert
Una sola afirmación, la que importa
Los dos resultados apuntan al mismo pago. Una sola línea, pero cubre todo lo que puede salir mal: si el UNIQUE no estuviera, si la captura del conflicto fallara, o si el segundo request devolviera un pago nuevo en vez del existente, este `expect` lo detecta.
¿Por qué importa?
El coste de que este test no exista
Sin él, el bug no aparece en desarrollo (nadie hace doble clic probando) y aparece en producción con un cliente real cobrado dos veces. Es un fallo que se detecta tarde, cuesta dinero y erosiona la confianza. Cuatro líneas de test contra eso es la mejor relación coste/beneficio del repositorio.
Los 15 niveles, uno por uno
Cada ficha trae la pregunta que responde el nivel y —más importante— su punto ciego. Ningún nivel es suficiente por sí solo; el conjunto funciona porque los puntos ciegos de uno los cubre otro. Filtra por cuándo corre o por si bloquea el deploy.
01
Unitario / lógica pura
¿Esta función devuelve lo correcto para cada entrada, incluidas las raras?
Herramienta
Vitest
Volumen
696 tests sobre funciones sin efectos
Vive en
tests/*.test.ts (39 archivos)
Punto ciego: No sabe nada de la base de datos, la red ni el navegador. Una función puede ser perfecta y el sistema estar roto.
En cada pushbloquea
02
Integración con BD real
¿El UNIQUE, la transacción y la concurrencia se comportan como creo?
Punto ciego: Es un "baseline" pasivo: no intenta explotar nada, solo detecta cabeceras/config ausentes. Un DAST activo encontraría más, y también rompería más cosas.
En cada push
15
Pruebas de carga
pendiente
¿Qué pasa con la latencia cuando entran 1000 personas a la vez?
Herramienta
k6 (pendiente)
Volumen
Fase 5 del plan del LAB, aún sin implementar
Vive en
docs/plan-lab-fases-pendientes.md
Punto ciego: Nunca puede correr contra producción: Vercel factura por invocación y Turso tiene cuota. Va contra un preview desechable.
Manual
12 decisiones y el problema que las provocó
Esta es la parte que no sale en ningún tutorial. Cada decisión aquí abajo salió de algo que se rompió, se desincronizó o costó una tarde de depuración. El formato es siempre el mismo: síntoma → causa → decisión → dónde vive.
Base en archivo, nunca `:memory:`
Síntoma Los tests de concurrencia fallaban con «no such table», pero solo los que abrían una transacción.
Causa Una transacción de libSQL abre otra conexión, y una base en memoria no comparte tablas entre conexiones.
Decisión Toda base de prueba es un archivo en el directorio temporal del sistema, con nombre único por PID y timestamp.
tests/payments.test.ts, tests/cobros-db.test.ts
Migrar con el migrador de producción
Síntoma Un test pasaba en verde contra un esquema que ya no existía en producción.
Causa El CREATE TABLE estaba escrito a mano en el test y se desincronizó cuando otro trabajo añadió columnas.
Decisión Los tests nuevos migran con `drizzle-orm/libsql/migrator` apuntando a la carpeta drizzle/ real. Mismo esquema que producción, por construcción.
tests/contracts.test.ts
Sembrar en `webServer`, no en `globalSetup`
Síntoma El servidor de e2e arrancaba contra una base que no existía.
Causa Playwright levanta el webServer ANTES de ejecutar globalSetup. Sembrar allí llega tarde.
Decisión La siembra es parte del propio comando del webServer: `node scripts/seed-e2e.mjs && npm run dev`.
playwright.config.ts
`astro dev` y no `astro preview` en e2e
Síntoma El build de producción no se podía servir localmente para probarlo.
Causa El adaptador de Vercel no soporta `astro preview`; haría falta `vercel dev`.
Decisión Los e2e corren contra `astro dev`. El middleware —que es justo lo que verifican— se comporta igual en dev.
playwright.config.ts
Un centinela para probar el aislamiento
Síntoma Necesitaba demostrar, no afirmar, que la demo pública jamás muestra datos reales.
Causa La demo usa una base Turso distinta seleccionada por AsyncLocalStorage. Un error de contexto filtraría datos de clientes.
Decisión La base «principal» de e2e se siembra con el prefijo `CENTINELA-REAL `. Un test afirma que ese texto no aparece nunca en la demo. Si el aislamiento se rompe, el test lo grita.
e2e/demo.spec.ts, playwright.config.ts
El testing condicionó la arquitectura
Síntoma Un módulo importado desde el navegador reventaba al arrastrar `node:crypto` y la conexión a BD.
Causa Las páginas .astro con <script> ejecutan ese código en el cliente, donde no existe Node.
Decisión Se separó en un módulo puro isomorfo y otro solo-servidor: cobros.ts / cobros-crypto.ts, payments-state.ts / payments.ts. Como efecto secundario, la lógica pura quedó trivial de testear.
src/lib/payments-state.ts, src/lib/cobros.ts
La cobertura miente, por eso hay mutación
Síntoma Un porcentaje de cobertura, sea alto o bajo, no me decía si los tests comprobaban algo.
Causa La cobertura mide ejecución, no verificación. Un test sin un solo `expect` cubre líneas igual.
Decisión Stryker muta el código y vuelve a correr la suite contra cada mutante. Si un mutante sobrevive, hay una línea que ningún test defiende.
stryker.config.json, src/lib/lab/mutation.ts
La mutación NO corre en cada push, a propósito
Síntoma Un pipeline que tarda 40 minutos es un pipeline que la gente evita.
Causa Mutar cada línea y re-ejecutar la suite contra cada mutante son miles de ejecuciones. Es lento por diseño, no por estar mal configurado.
Decisión Job aparte: `workflow_dispatch` o domingos a las 08:00 UTC. Nunca bloquea un PR.
.github/workflows/mutation.yml
Los escáneres registran, no bloquean
Síntoma Un advisory nuevo sobre una dependencia transitiva tumbaría el pipeline sin que yo pueda arreglarlo.
Causa npm audit, CodeQL y axe reportan cosas que a veces no dependen de mi código.
Decisión `continue-on-error: true` en los tres. Los hallazgos se deduplican por fingerprint, persisten entre corridas y el semáforo real vive en el panel LAB.
.github/workflows/security.yml, a11y.yml
Todos los secrets de CI son opcionales
Síntoma Un fork o un repo recién clonado no tiene mis tokens y el pipeline entero fallaría.
Causa VERCEL_TOKEN, LAB_INGEST_TOKEN y NTFY_TOPIC son míos, no del proyecto.
Decisión Cada paso comprueba si el secret existe y, si no, emite un ::warning:: y sigue. Sin token no hay rollback ni reporte, pero el pipeline no se rompe. Es el mismo principio fail-open del middleware.
.github/workflows/ci.yml
El último test corre en producción
Síntoma Un deploy verde en CI puede estar roto en producción por una variable de entorno que solo existe en local.
Causa CI prueba el código; producción ejecuta el código *más* su entorno. Son dos cosas distintas.
Decisión Tras el deploy, el pipeline espera a ver su propio SHA en /api/health, hace 3 health checks y revierte solo si no pasan 2. La verificación no termina en el merge.
job verify-production de ci.yml
Probar que el sistema falla bien
Síntoma Sabía qué pasaba cuando todo funcionaba; no tenía ni idea de qué pasaba cuando la BD tardaba 5 segundos.
Causa Ningún test convencional prueba el comportamiento degradado.
Decisión Flags de caos en BD que el middleware aplica a rutas concretas: latencia o error 500, con TTL acotado y una lista de rutas que jamás se pueden romper. Y 12 tests que prueban al propio motor de caos.
src/lib/chaos.ts, tests/chaos.test.ts
Cobertura vs. mutation score
Es la distinción más útil de toda esta página y la que casi nadie hace. La cobertura mide qué líneas se ejecutaron; el mutation score mide qué líneas están defendidas. No son lo mismo, y confundirlas produce suites que dan un 90% de cobertura sin comprobar nada.
Este test da 100% de cobertura
it('formatea el monto', () => {
formatCOP(1500000) // se ejecuta…
}) // …pero no afirma NADA
La línea de formatCOP aparece como cubierta. El reporte de cobertura está en verde. Y sin embargo, la función podría devolver cualquier cosa.
La mutación lo desenmascara
// Stryker cambia el separador…
- return `$ ${miles} COP`
+ return `${miles}`
// …vuelve a correr la suite: SIGUE VERDE
// → mutante SURVIVED
Un mutante que sobrevive es la prueba de que esa línea no tiene a nadie vigilándola. Es exactamente el caso que encontró Stryker en money.ts: cinco literales de cadena sin un solo test que comprobara la etiqueta.
Killed
Se mutó la línea y algún test falló. Bien: esa línea está defendida.
Survived
Se mutó la línea y toda la suite siguió en verde. Hay un agujero.
NoCoverage
Ningún test ejecuta siquiera esa línea. Ni se intentó matar al mutante.
Timeout
La mutación provocó un bucle infinito. Cuenta como detectada.
Y sí, 59.8% de cobertura no es una cifra de la que presumir.Se mide sobre src/lib/** entero — 2.164 líneas — incluyendo módulos que solo se ejecutan contra servicios externos (pasarela real, notificaciones, RDAP) y que no se prueban con tests unitarios sino en el LAB y en producción. Inflarla sería trivial: bastaría con excluir esos archivos del cálculo. Se deja el número completo porque un porcentaje maquillado no informa de nada, y porque el dato que de verdad importa —si las líneas que sí se prueban están defendidas— lo da el mutation score, no este.
Por qué esto no corre en cada push: Stryker genera un mutante por cada operador, literal y condición de src/lib/**, y vuelve a ejecutar la suite entera contra cada uno. Son miles de ejecuciones. Un pipeline de cuarenta minutos es un pipeline que la gente aprende a saltarse, así que la mutación vive en su propio job semanal (mutation.yml, domingos 08:00 UTC) y nunca bloquea un PR.
Lo que todavía no está
Ninguna suite de pruebas está terminada, y una que se presenta como completa está mintiendo o no se ha mirado con suficiente atención. Estos son los huecos conocidos de esta, con el motivo por el que siguen abiertos.
Pruebas de carga con k6
Fase 5 del plan del LAB. Los scripts y la tabla están diseñados; falta el VERCEL_TOKEN que permite crear el preview desechable contra el que se dispara la carga. Nunca irá contra producción: Vercel factura por invocación y Turso tiene cuota de filas.
Evidencia de usabilidad
La metodología de 6 pasos está documentada y aplicada a un flujo real, pero la columna «Evidencia» sigue vacía: falta ejecutar la prueba con participantes de verdad. Un guion sin participantes no es una prueba de usabilidad.
Regresión visual
No hay comparación de capturas entre versiones. Un cambio de CSS que rompa el layout en móvil pasaría los 543 tests sin despeinarse. Es el hueco más grande que tiene hoy la suite.
Tests de rendimiento del cliente
Se recogen Web Vitals reales de visitantes (RUM), pero nada falla si una página empeora. Falta un presupuesto de rendimiento que bloquee un PR que degrade el LCP.
Hallazgos abiertos ahora mismo: 32 sin resolver reportados por los escáneres automáticos, de los cuales 25 son de severidad alta o crítica. Son hallazgos reales, no una demo: se deduplican por huella y conservan su estado entre corridas. El detalle vive en el panel privado; aquí solo el agregado.
Córrelo tú mismo
Todo lo de esta página se puede reproducir clonando el repositorio. Requiere Node ≥ 22.12 — con Node 20 el build de Astro se rompe. Ningún comando de aquí necesita credenciales: las bases de prueba son archivos desechables.
npm testLos 799 tests de Vitest. ~4 segundos.
npm run test:watchModo interactivo: re-ejecuta solo lo que toca el archivo que estás editando.
npm run test:coverageGenera el reporte HTML en coverage/ para ver qué líneas no toca nadie.
npm run test:e2ePlaywright. Siembra dos bases desechables y levanta el servidor solo.
npm run test:e2e:uiEl inspector de Playwright: ver el navegador paso a paso y depurar un test.
npm run test:contractsSolo los contratos de API, contra una BD migrada con el migrador real.
npm run test:mutationStryker sobre src/lib. Tarda mucho, avisado quedas.
npx astro checkType-check completo. No es un test, pero atrapa lo mismo que muchos.