
Fredy Acuna / October 30, 2026 / 10 min read
Un agente de IA te dirá con gusto que "todas las pruebas pasan". A veces es cierto. A veces corrió la suite equivocada, escribió una prueba que no puede fallar u omitió justo la que importaba.
Por eso dejé de tratar las pruebas como algo que el agente hace de lado. Las pruebas son el contrato entre el agente y yo: defino las reglas una vez, los repositorios las hacen cumplir de forma mecánica y el agente reporta evidencia observada en lugar de opiniones.
Este post cubre las dos mitades: la disciplina de pruebas que le doy al agente y lo que realmente existe en tres de mis proyectos:
Viven en mi configuración global de agentes (parte del flujo ODD de Gentle AI que describí en otro post), así que cada sesión las recibe sin que yo repita nada:
mktemp -d, ejecuta los comandos afectados ahí y sobre el cambio, y compara stdout, stderr y el código de salida. Cualquier diferencia que nadie pidió es una regresión.HOME, TMPDIR y cachés temporales van a un directorio de mktemp -d para que ningún archivo suelto termine en un commit.El resultado de todo eso son líneas de evidencia como esta, de un documento de feature real de HitZero:
- [x] T1 Cron: lapse step skips unpriced plans, no dunning for them; unit + integration tests.
(RED: unit 1 failed/144 skipped with -t, int 2 failed|1 passed; GREEN: billing-routes 145 passed,
int 3 passed; commit 8e07864; route: delegated writer)
RED y GREEN son números que alguien vio, atados a un commit.
HitZero es un monorepo con pnpm y Turborepo. El vitest.workspace.ts de la raíz es mínimo:
import { defineWorkspace } from 'vitest/config';
export default defineWorkspace(['workers/api', 'packages/types', 'packages/auth']);
La estrategia de pruebas tiene tres capas, documentadas en el CLAUDE.md del repositorio para que el agente las lea antes de tocar cualquier cosa:
| Capa | Comando | Docker | Qué cubre |
|---|---|---|---|
| Unitaria | pnpm test | No | Lógica pura y unidades por worker con Vitest (excluye *.int.test.ts) |
| Integración | pnpm --filter @hitzero/api-worker test:int | Sí | La API contra un postgres:18-alpine real y efímero vía Testcontainers, con las migraciones aplicadas y TRUNCATE entre pruebas |
| E2E | pnpm --filter @hitzero/e2e test | Sí | Playwright controlando Chromium contra Postgres, Redis, la API, la plataforma y un reverse proxy |
También hay una configuración aparte de Playwright para la PWA (test:pwa), con specs como pwa-threat-matrix.spec.ts.
La capa de integración usa Postgres 18 a propósito: producción corre Postgres 18 self-hosted, y probar con otra versión mayor validaría un comportamiento del planner y de SQL que producción no tiene.
HitZero es multi-tenant. Las suites de rutas mockean los repositorios, así que la cláusula WHERE org_id = :orgId nunca se ejecuta en ellas. Solo las suites de integración prueban el aislamiento contra SQL real.
Nada obligaba a que un repositorio nuevo viniera con una de esas suites, así que existe una prueba estructural que lo obliga. Corre en la suite unitaria rápida, sin Docker:
/**
* Structural guard — every org-scoped repository must own a multi-tenant
* isolation suite running against a real Postgres.
* ...
* It runs in the fast, Docker-free unit suite (pure filesystem reads) so
* it fails on every PR rather than only when someone remembers to run
* `pnpm test:int` locally.
*/
Lee src/db/repositories/, lee test/integration/ y falla si un repositorio con alcance de organización no es importado por ninguna suite de aislamiento. Las excepciones viven en un registro explícito, y agregar una está documentado como una decisión de seguridad.
Este es el tipo de prueba que mejor funciona con agentes. Un agente que crea un repositorio nuevo no necesita recordar la regla: la suite se la dice.
Cada commit pasa por lefthook:
pre-commit:
parallel: true
jobs:
- name: biome check
glob: "*.{ts,tsx,js,jsx,json}"
run: pnpm biome check --no-errors-on-unmatched --staged {staged_files}
- name: ls-lint
run: pnpm lint:names
Biome es el único linter y formateador. ls-lint obliga a usar kebab-case en los nombres de archivo. Las reglas del repositorio prohíben --no-verify y biome-ignore sin aprobación.
También está scripts/check-zod-pin.mjs, una guarda que analiza pnpm-lock.yaml y falla si @hookform/resolvers llega a resolver Zod a algo distinto de 3.x. Sin ese pin, una dependencia transitiva resuelve a Zod 4 y rompe el typecheck de los formularios de la plataforma; el script hace imposible una regresión silenciosa del pin.
.github/workflows/quality.yml corre solo en pull requests desde una rama release/* hacia main. Tiene cuatro jobs: Check (pin de Zod, lint, nombres de archivo, typecheck, pruebas unitarias y build), Integration, E2E (incluida la suite de la PWA) y Seed (migraciones, seed y los scripts de demo, cada script de demo ejecutado dos veces para exigir su idempotencia).
El trabajo de features se verifica localmente con scripts/local-ci.sh. Su encabezado explica por qué existe mejor de lo que yo podría:
# Why it is a script and not a habit: the habit failed. The substitution was
# written from memory of the workflow, it omitted `test:pwa`, and that gap let a
# regression sit on main for four merges while every report said "E2E 22/22".
# A number that looks complete is worse than one that admits a hole.
#
# The drift guard below is the point of the whole file. It compares the commands
# this script runs against the `run:` lines in the workflow, and refuses to start
# when they disagree — so a step added to CI cannot be silently missing here.
El script lista cada comando que corre el CI y verifica las dos direcciones contra el archivo del workflow antes de ejecutar nada.
El repositorio también tiene un VIOLATIONS.md: un registro de cada vez que un asistente de IA rompió una regla de CLAUDE.md, con la regla, lo que pasó y la corrección. Las entradas nunca se eliminan. Una registra un --no-verify que se coló en una cadena de comandos de respaldo; la corrección fue enmendar el commit con los hooks activos antes de hacer push.
Suena burocrático, pero es el ciclo de retroalimentación que convierte un error en una regla que la siguiente sesión lee.
bici.taxi no tenía ningún runner de pruebas hasta la feature de despacho, así que la primera tarea de esa feature agregó Vitest. Hoy:
pnpm test ejecuta vitest run sobre tests/**/*.test.{ts,tsx} en la raíz, cubriendo cosas como la selección de conductores para el despacho, las plantillas de mensajes y el renderizado de páginas.server/tests.flutter analyze && flutter test en mobile/test.El CLAUDE.md del proyecto lo resume en una línea: "TDD is on: write the failing test, see it fail, implement, refactor."
El CI usa GitHub Actions con filtros por ruta. server.yml ejecuta typecheck, pruebas y build en pull requests que tocan server/ o db/. mobile.yml ejecuta dart format --set-exit-if-changed, flutter analyze, flutter test y un build de APK de debug en pull requests que tocan mobile/. deploy.yml compila y despliega la app web en Cloudflare Pages con cada push a main.
La suite de Flutter tiene el mismo tipo de guarda estructural que HitZero. Esta falla si algún widget muestra un texto literal en lugar de usar el catálogo de textos:
/// Widget text must come from the strings catalog (`lib/l10n/app_es.arb`),
/// never from a literal in a widget.
void main() {
final textWidget = RegExp(
r'''\b(Text|SelectableText|Tooltip|TextSpan)\(\s*(text:\s*)?['"]''',
);
// ...
test('widgets take their text from the strings catalog', () {
// ...
expect(offenders, isEmpty, reason: 'Move these strings to app_es.arb');
});
}
Una prueba hermana verifica que cada texto en español tenga su traducción al inglés con los mismos placeholders. Los agentes son excelentes agregando UI y pésimos recordando la internacionalización. Ahora no necesitan recordarla.
Las herramientas de NOVALYX son scripts de Python: un evaluador de letras, generadores de boards, plates y clips, helpers de render y un generador de sitio estático. Se prueban con pytest en tools/tests:
tools/.venv/bin/python -m pytest tools/tests -q
La lógica pura recibe pruebas parametrizadas:
@pytest.mark.parametrize("word,n", [
("internet", 3), ("cartoon", 2), ("forty-two", 3), ("AI-pocalypse", 5),
("the", 1), ("made", 1), ("table", 2), ("jumped", 1), ("wanted", 2),
])
def test_syllable_count(word, n):
assert ls.count_syllables(word) == n
Las pruebas del sitio usan un fixture de sesión en conftest.py que ejecuta el build real una sola vez y se omite con un mensaje claro cuando falta node_modules, en lugar de fallar con un error confuso.
Nada de esto necesita una plataforma sofisticada. Es Vitest, Playwright, Testcontainers, las pruebas de Flutter, pytest, un git hook y algo de shell. La diferencia es que el agente está obligado a ejecutarlos y mostrar lo que vio.