Mike (@mikerb95)CodeByMike

¿Qué pasa cuando hago git push?

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.

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.

  1. Local799 tests en verde. Push.
  2. PushCI, Security y Accessibility arrancan en paralelo.
  3. CIVitest 799/799 · build OK · 45 e2e en verde.
  4. DeployVercel publica. /api/health ya devuelve el SHA nuevo.
  5. Verificación3 de 3 health checks con HTTP 200.
  6. 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.

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')
// BD libsql en archivo temporal: las transacciones abren otra
// conexión y ':memory:' no comparte tablas entre ellas.
vi.mock('../src/db', async () => {
  const file = join(tmpdir(), `payments-test-${process.pid}.db`)
  const client = createClient({ url: `file:${file}` })
  return { db: drizzle(client, { schema }), __client: client }
})

it('doble clic: requests concurrentes con la misma clave crean UN pago', async () => {
  const key = `race-${crypto.randomUUID()}`
  const [a, b] = await Promise.all([
    createPaymentIdempotent(checkoutInput(key)),
    createPaymentIdempotent(checkoutInput(key)),
  ])
  expect(a.payment.id).toBe(b.payment.id)
})
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.

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?

Herramienta
Vitest + libSQL en archivo temporal
Volumen
98 tests contra una base de verdad
Vive en
payments, cobros-db, portal-*, security-blocklist-db

Punto ciego: Es SQLite local, no Turso remoto: no ve latencia de red ni límites de cuota.

En cada pushbloquea
03

Contratos de API

¿La forma de la respuesta cambió sin que nadie se diera cuenta?

Herramienta
Vitest + Zod
Volumen
5 tests sobre 4 endpoints clave
Vive en
tests/contracts.test.ts + src/lib/contracts.ts

Punto ciego: Valida la forma, no el significado. Un campo puede tener el tipo correcto y el valor equivocado.

En cada pushbloquea
04

End-to-end

¿Un humano con un navegador puede completar el flujo de principio a fin?

Herramienta
Playwright (Chromium)
Volumen
45 tests en 6 specs
Vive en
e2e/*.spec.ts

Punto ciego: Lento y frágil por naturaleza. Por eso son 45 y no 450: cubren los flujos que perder duele, no cada botón.

En cada pushbloquea
05

Cobertura de código

¿Qué parte del código no ejecuta ni una sola prueba?

Herramienta
@vitest/coverage-v8
Volumen
1416 de 2440 líneas de src/lib/**
Vive en
vitest.config.ts → coverage/

Punto ciego: Enorme: dice que la línea se ejecutó, no que se haya comprobado nada sobre ella. De ahí el nivel 6.

En cada push
06

Mutation testing

Si rompo esta línea a propósito, ¿algún test se entera?

Herramienta
Stryker + runner de Vitest
Volumen
umbrales 80 / 60 / 50 sobre src/lib/**
Vive en
stryker.config.json

Punto ciego: Carísimo en tiempo: son miles de ejecuciones de la suite. Por eso es semanal y nunca bloquea un PR.

Semanal
07

SAST de dependencias

¿Alguna librería que uso tiene una vulnerabilidad publicada?

Herramienta
npm audit → panel LAB propio
Volumen
hallazgos deduplicados por fingerprint
Vive en
scripts/npm-audit-scan.mjs

Punto ciego: Solo ve lo que ya está publicado como advisory. Un 0-day no aparece.

En cada push
08

SAST de código

¿Escribí yo algún patrón peligroso (inyección, XSS, secreto expuesto)?

Herramienta
CodeQL (javascript-typescript)
Volumen
pestaña Security del repo
Vive en
.github/workflows/security.yml

Punto ciego: Análisis estático: no ejecuta nada. Genera falsos positivos y no ve fallos de lógica de negocio.

En cada push
09

Accesibilidad

¿Puede usar esto alguien con lector de pantalla o sin ratón?

Herramienta
axe-core + Playwright
Volumen
páginas públicas, violaciones WCAG reales
Vive en
scripts/a11y-scan.mjs

Punto ciego: Las herramientas automáticas detectan ~30-40% de los problemas de accesibilidad. El resto necesita a una persona.

En cada push
10

Verificación en producción

Lo que acabo de desplegar, ¿está vivo de verdad?

Herramienta
curl + /api/health + vercel rollback
Volumen
3 health checks, se exigen 2 sanos
Vive en
job verify-production de ci.yml

Punto ciego: Comprueba que el sistema responde, no que responda bien. Un deploy puede estar «sano» y devolver datos incorrectos.

En cada pushbloquea
11

Chaos engineering

Cuando algo falle de verdad, ¿el sistema falla bien o se lleva todo por delante?

Herramienta
Flags en BD + middleware propio
Volumen
12 tests que prueban al propio motor de caos
Vive en
src/lib/chaos.ts, /admin/lab/chaos

Punto ciego: Solo inyecta los fallos que se me ocurrieron. La realidad tiene más imaginación.

Manual
12

Monitoreo sintético

¿Sigue funcionando ahora mismo, a las 3 de la madrugada?

Herramienta
Monitores propios + cron externo
Volumen
9 monitores, sondeo cada ~5 min
Vive en
/admin/monitors → /status

Punto ciego: Prueba desde fuera y sin sesión: no ve nada de lo que pasa detrás del login.

Continuo (24/7)
13

Usabilidad con usuarios

Funciona, pero ¿alguien que no lo construyó consigue usarlo?

Herramienta
Metodología de 6 pasos
Volumen
1 flujo documentado (descarga de CV)
Vive en
/docs/usability-testing

Punto ciego: No se automatiza: hace falta gente real. Es el único nivel donde el resultado es una observación, no un booleano.

Manual
14

DAST (análisis dinámico)

¿El sitio corriendo de verdad tiene una vulnerabilidad que el análisis estático no puede ver?

Herramienta
OWASP ZAP baseline
Volumen
contra el preview de cada PR, nunca contra producción
Vive en
.github/workflows/dast.yml, scripts/zap-ingest.mjs

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.

Glosario

Assert
La afirmación que hace que un test sea un test. Sin al menos un assert, el test solo ejecuta código.
Fixture
Datos de ejemplo preparados para un test. Aquí, por ejemplo, un recorte real de un reporte de Stryker.
Seed
Poblar una base vacía con datos conocidos antes de probar. Los e2e siembran dos bases en cada corrida.
Flaky
Test que a veces pasa y a veces no sin que cambie el código. Es peor que un test que falla siempre: enseña a ignorar el rojo.
Mutante
Una copia del código con un cambio deliberado (un > por un >=). Si la suite sigue verde, el mutante «sobrevive».
Mutation score
Porcentaje de mutantes que los tests detectan. Mide la calidad de los tests, no la del código.
Cobertura
Porcentaje de líneas que se ejecutan durante los tests. Dice qué se tocó, no qué se comprobó.
E2E
End-to-end: un navegador real recorriendo un flujo completo, como lo haría una persona.
Contrato
Un esquema que congela la forma de la respuesta de una API. Cambiarla obliga a actualizar el esquema a propósito.
Idempotencia
Que repetir la misma operación no produzca un efecto nuevo. Sin ella, un doble clic cobra dos veces.
SAST
Static Application Security Testing: buscar vulnerabilidades leyendo el código, sin ejecutarlo.
Fail-open
Ante un fallo del propio sistema de defensa, dejar pasar la petición. Una defensa que tumba el sitio que protege es una vulnerabilidad nueva.
Health check
Un endpoint que responde «estoy vivo». Aquí además devuelve el SHA del commit desplegado, que es lo que permite verificar el deploy.
Rollback
Volver a la versión anterior. Aquí es automático: si el health check falla, nadie tiene que intervenir.
Error budget
El margen de caída que permite un objetivo de disponibilidad. Con 99.5% mensual son ~3.6 horas.
Chaos engineering
Provocar fallos a propósito y de forma controlada para descubrir cómo se degrada el sistema antes de que pase de verdad.