Pruebas¶
Cuatro capas, un solo runner y una puerta de cobertura que tumba la build por debajo del 100% en la capa de plataforma.
La versión corta de qué ejecutar: los tests que cubren el cambio mientras escribes, y las suites una vez antes de hacer push.
Ejecutar los tests¶
Mientras escribes, ejecuta lo que cubre el cambio; la suite es la puerta previa al push
Un archivo responde en aproximadamente un segundo donde la suite tarda minuto y medio, y dice lo mismo sobre el cambio.
cd backend
uv run pytest tests/test_capability_registry.py -q # one file
uv run pytest tests/test_capability_registry.py -k drift # one behaviour
uv run pytest tests/api/test_workspace_routes.py -x -v # stop at the first failure
uv run pytest tests/integration -v --no-cov # the ones needing a database
Estos van en serie a propósito: lanzar workers para un solo archivo cuesta más que
el archivo. Los objetivos de suite completa —make test, make test-fast,
make test-integration, make test-cov— se reparten entre workers
(pytest -n auto --maxprocesses 4), lo que casi reduce a la mitad la suite de
integración, limitada por E/S; pytest-cov combina los datos de cada worker, así que
la puerta del 100% sigue igual. El tope es cuatro porque la suite unitaria depende de
los imports —cada worker importa la aplicación una vez— y no gana nada más, mientras
un auto sin tope en un portátil multinúcleo es más lento que en serie, todo ello
arranque de workers (#520).
Cada ejecución se baraja, y un test que pasó ayer puede haber estado dependiendo del orden
pytest-randomly imprime la semilla en la cabecera
(Using --randomly-seed=1697040112). Repite esa semilla en serie para
recuperar el mismo orden: -n auto no fija qué worker ejecuta qué.
Un test que depende del orden —uno que pasa solo porque algo anterior dejó estado detrás— es el clásico "verde en mi portátil, rojo en CI", y una suite que siempre corre en orden de recolección nunca hace la pregunta. CI la hace en un orden nuevo cada ejecución.
uv run pytest tests/ -q --randomly-seed=1697040112 # that order again, serially
uv run pytest tests/ -q -p no:randomly # collection order, while bisecting
La semilla la elige el controlador una vez y se entrega a cada worker de xdist, así que
-n auto recoge un orden y no cuatro. No fija qué worker ejecuta qué: make test deja
xdist en su --dist load por defecto, que entrega cada test al worker que esté libre.
Un fallo que dependía de lo que compartía worker —el InterfaceError que encontró #571
es uno— vuelve repitiendo la semilla en serie, como arriba, y no reejecutando
make test. El plugin además resiembra random igual antes de cada test, así que
cualquier cosa que lo use para unicidad es única dentro de un test y se repite entre
ellos.
Hasta #571 el plugin estaba documentado pero no instalado, -p no:randomly no hacía
nada en silencio, y nada había ejercitado nunca esa afirmación.
Una vez, antes del push — make check lo ejecuta todo, en este orden:
make lint # ruff, ruff format, ty, vulture, deptry, eslint, prettier, tsc, the guards
make test # the suite plus the 100% gate on the platform layer
make db-check # alembic check — a model change with no migration fails here
make test-frontend-cov # the frontend suite plus its own gate
make build-frontend # next build — the route tree, which tsc and vitest do not see
make docs-build # mkdocs --strict — a dead link is a failure
make audit # the locked dependency set against the advisory database
Unos cinco minutos en serie, frente a los doce de CI en paralelo — con el job test
del backend como el cuello de botella de allí, que es lo que #520 está recortando. La
igualdad se mantiene en vez de afirmarse: el workflow llama a estos objetivos en vez de
repetir sus comandos, y tests/test_ci_parity.py falla si un job de los que bloquean
gana un paso que make check no ejecuta. Se ha desviado cuatro veces — ver
Comandos para lo que check deja fuera y por qué.
CI puede ejecutar menos jobs que check, y eso no es una desviación
test, test-frontend y e2e se saltan en un pull request cuyas rutas
modificadas no pueden afectarlos, y un check obligatorio en skipped deja pasar
igualmente un merge. En local no hay equivalente: check lo ejecuta todo.
Un cambio solo de documentación no ejecuta ninguno de los tres; uno solo de backend no
ejecuta ninguna suite de frontend. Quien decide es scripts/ci_changed_scope.py, yerra
hacia ejecutar, y Ramas
tiene la regla.
Dos formas de hacer push de algo que no se ha verificado
make test-fast se salta la cobertura, lo que lo convierte en la última palabra
equivocada antes de un push: la puerta es casi todo para lo que existen estos
comandos. Y pytest sin uv run coge el intérprete que haya en el path en vez
del 3.12 fijado.
Estructura de los tests¶
Cuatro capas, y a cuál pertenece un test lo decide lo que necesita, no de qué trata.
backend/tests/
├── conftest.py # the shared fixtures, and the test database's name
├── test_*.py # unit: one module, its dependencies mocked at the repository boundary
├── api/ # the app driven through `client`, grouped by the question asked
└── integration/
└── conftest.py # creates a database of its own, and drops it afterwards
tests/api/ está agrupado por qué se está preguntando, no por módulo de rutas:
algunos archivos toman un solo endpoint (test_admin_ratings_window.py), y
test_platform_routes.py barre una familia entera de una vez, que es por lo que
agents.py no tiene archivo propio. Busca la pregunta antes de buscar la ruta.
| Capa | Dónde | Para qué |
|---|---|---|
| Unitaria | tests/test_*.py |
Un módulo. Los repositorios se mockean; el servicio bajo prueba nunca |
| API | tests/api/, y algunos en el nivel superior |
La ruta: su puerta, su código de estado, qué llega al servicio |
| Integración | tests/integration/ |
Lo que solo responde una base de datos: un ORDER BY, un borrado en cascada, una restricción de unicidad, una consulta que de verdad está acotada al tenant |
| E2E | frontend/e2e/ |
Recorridos que cruzan todo el sistema — ver Tests del frontend |
No hay directorio tests/unit/: un test unitario es un test_*.py en la raíz de
tests/. La capa es lo que un test necesita, no dónde está, y el nivel superior
tiene bastantes que mueven la aplicación con un AsyncClient propio:
test_rag_document_listing.py, test_oauth_signin_exchange.py,
test_security_headers.py. Buscar cobertura de rutas existente solo bajo tests/api/
los dejará fuera.
Una excepción, y está en el nivel superior en vez de en integration/.
tests/test_migrations.py recorre la cadena entera de Alembic contra una base de datos
real, que él mismo crea y borra bajo un nombre propio, porque downgrade base borra
todas las tablas y heredar POSTGRES_DB vació una vez la base de datos de trabajo de
alguien. Lo recoge un pytest tests/ corriente. No está en integration/ porque no
usa en absoluto el fixture db de ese paquete: ejecuta alembic en subprocesos.
Async: anyio, no pytest-asyncio¶
o @pytest.mark.anyio sobre el test, que es lo que hace tests/api/test_users.py
donde solo parte del archivo es asíncrona. Las dos formas valen; la de módulo es la
costumbre aquí porque casi todos los archivos son asíncronos de principio a fin.
@pytest.mark.asyncio no funciona aquí, y no hay ningún asyncio_mode que lo arregle
La suite corre sobre anyio. Un async def sin marcar falla en la recolección
con un mensaje sobre el framework y no sobre el test, así que se lee como un
entorno roto nada más entrar.
Un async def sin marcar no es un aprobado silencioso: pytest 9 lo falla en la
recolección con "async def functions are not natively supported" y lista los plugins
que lo arreglarían. El fixture anyio_backend fija asyncio, porque es lo que ejecuta
uvicorn.
Fixtures clave (tests/conftest.py)¶
Cinco. Ninguno es un test_user ni un cliente con sesión iniciada, y ese es el punto:
un llamante autenticado es un override de dependencia, así que un test dice qué
autoridad está ejercitando en vez de heredar una. tests/api/test_users.py construye un
auth_client propio con exactamente esos overrides: un fixture local para el archivo
que lo necesita, no uno compartido que heredan todos.
| Fixture | |
|---|---|
anyio_backend |
Fija asyncio, y nadie lo nombra: lo pide anyio |
client |
httpx.AsyncClient sobre ASGITransport(app=app) — no el TestClient de Starlette. Sobrescribe get_db_session y get_redis, y limpia app.dependency_overrides después |
mock_db_session |
Un AsyncMock. Su info es un dict de verdad, porque ahí es donde spawn_after_commit encola trabajo |
mock_redis |
Un MagicMock(spec=RedisClient) con los métodos asíncronos simulados |
api_key_headers |
La cabecera de servicio a servicio, para una ruta detrás de ValidAPIKey |
tests/integration/conftest.py añade los que tocan una base de datos. El paquete
rechaza cualquier base de datos cuyo nombre no contenga ni test ni ci, y vacía todas
las tablas entre tests. Solo se salta a sí mismo cuando no hay ninguna alcanzable
fuera de CI: con CI puesto lanza un error, porque un salto y un servicio de Postgres
que no arrancó se leen igual en la salida de pytest y solo uno de los dos es aceptable
en un runner.
| Fixture | |
|---|---|
db |
Una AsyncSession real: lo que toma casi todo test de integración |
engine |
El AsyncEngine que hay detrás, para un test que necesita una sesión que db no puede ser: más de una — una carrera, una escritura concurrente, dos transacciones que tienen que entrelazarse, donde una AsyncSession compartida entre ellas no es una segunda conexión sino una corrompida— o una que el código bajo prueba se crea para sí mismo, que es como los tests de RAG le dan a PgVectorStore su propio async_sessionmaker. Lo toman dieciocho archivos |
database_url, schema_url |
De alcance de sesión, y la razón por la que los dos de arriba son seguros: nombran la base de datos desechable y crean su esquema una sola vez |
Escribir tests¶
Nombra el comportamiento, no la función, para que un fallo diga qué se rompió:
test_a_grant_widens_access_without_promoting_the_member, no test_resolve.
Un test de servicio¶
import pytest
from unittest.mock import AsyncMock
from uuid import uuid4
from app.core.exceptions import NotFoundError
from app.repositories import user as user_repo
from app.services.user import UserService
pytestmark = pytest.mark.anyio
async def test_an_unknown_user_is_a_refusal_rather_than_a_none(monkeypatch, mock_db_session):
monkeypatch.setattr(user_repo, "get_by_id", AsyncMock(return_value=None))
service = UserService(mock_db_session)
with pytest.raises(NotFoundError):
await service.get_by_id(uuid4())
El repositorio se mockea y el servicio no. Un test que mockea aquello que está probando pasa cuando la implementación se borra.
Un test de API¶
El llamante es un override, que es lo que hace que el rechazo se pueda probar:
import pytest
from httpx import AsyncClient
from uuid import uuid4
from app.api import deps
from app.core.permissions import AuthContext, OrgRoleName
from app.main import app
pytestmark = pytest.mark.anyio
async def test_creating_an_agent_without_agents_edit_is_refused(client: AsyncClient):
# A role, not a permission list: `AuthContext` reads its own permissions out
# of `ROLE_PERMS` by name, so the test exercises the catalog rather than a
# set it invented.
viewer = AuthContext(
user_id=uuid4(), organization_id=uuid4(), role=str(OrgRoleName.VIEWER)
)
app.dependency_overrides[deps.get_auth_context] = lambda: viewer
response = await client.post("/api/v1/agents", json={"name": "Support"})
assert response.status_code == 403
tests/api/test_platform_routes.py hace esto barriendo, en vez de con una aserción por
ruta: la puerta que lleva una ruta es una tabla, y una tabla se recorre en vez de
repetirse. Recorre los prefijos de plataforma —/agents, /runs, /approvals,
/spend, /stats, /skills y el resto de _PLATFORM_PREFIXES—, que es por lo que casi
ninguno de esos tiene archivo propio, y por lo que /auth, /organizations y /users
siguen necesitando el suyo: el barrido pasa de largo sobre ellos.
Un test de integración¶
Solo para lo que una sesión mockeada no puede responder, que suele ser una ordenación,
una restricción o un borrado en cascada. tests/integration/test_message_order.py es la
forma: un turno escribe su pregunta y su respuesta dentro de una transacción, así que
ambas filas llevan el mismo created_at al microsegundo y el empate lo desempata una
columna y no el planificador.
import pytest
from app.repositories import conversation as conversation_repo
from app.services.transcript import TranscriptService
pytestmark = pytest.mark.anyio
async def test_the_question_precedes_the_answer_it_got(db):
# `_conversation` and `_run` are the file's own builders - a row per table,
# added to `db` and flushed. Nothing is mocked; that is the whole point.
conversation = await _conversation(db)
run = await _run(db, conversation)
await TranscriptService(db).record(run, prompt="ask", answer="answer")
written = await conversation_repo.get_messages_by_conversation(db, conversation.id)
assert [message.role for message in written] == ["user", "assistant"]
Aquel bug era de Postgres, y una sesión mockeada habría pasado contra el esquema que no tenía desempate ninguno.
Qué merece un test aquí¶
Cubre el rechazo
Casi todo el valor de esta plataforma está en lo que rechaza, así que el rechazo es el caso que tiene que existir:
- una lectura entre tenants — incluida una en la que el llamante es dueño de la fila;
- un alcance no concedido;
- un budget comprobado antes de la petición al modelo, y registrado incluso cuando el run falla;
- un spec rechazado al publicar y no en tiempo de ejecución;
- ningún secreto en claro en ninguna respuesta, línea de log o entrada de auditoría.
.claude/rules/testing.md y el skill backend-tests llevan el resto: las trampas, los
ejemplos resueltos y la historia detrás de cada uno. Esta página es la forma de la
suite; ninguna repite a la otra.
Tests del frontend¶
Ejecútalos desde frontend/. En la raíz del repositorio vitest no encuentra
configuración, informa de bastante más de cien fallos fantasma y deja un node_modules/
suelto.
cd frontend
bunx vitest run src/components/chat/usage-strip.test.tsx # one spec, ~2s
bunx vitest run src/components/chat # one directory
bun run test # watch mode
bun run test:coverage # the suite plus the gate CI applies
bun run test:e2e # Playwright
bun run test:e2e --headed # ...with a browser to watch
bun run test:run no mide cobertura, así que no puede responder si el job
test-frontend va a pasar: la puerta quiere el 100% de líneas, sentencias y funciones y
el 97,5% de ramas sobre src/{app/api,lib,stores,hooks} y casi todo src/components.
Dos plazos, ambos dimensionados para una máquina cargada¶
Un spec con mucho renderizado no es lento por estar mal escrito; es lento porque varios
miles de ellos comparten diez núcleos con lo que sea que esté corriendo. testTimeout en
vitest.config.ts es de 15 s y el asyncUtilTimeout de Testing Library en
vitest.setup.ts es de 5 s, ambos subidos desde unos valores por defecto que solo
aguantan en una máquina ociosa.
Los números vienen de ejecutar la suite entera de cuatro maneras (#862):
| Test individual más lento | Desnudo | Con --coverage |
|---|---|---|
| Diez núcleos ociosos | 1,7 s | 2,9 s |
| Con 32 bucles ocupados al lado | 5,4 s | 6,1 s |
Con esa carga el viejo valor por defecto de 5 s fallaba tres tests por ejecución — tres
distintos cada vez, porque qué archivos comparten worker se decide por tiempos, y
tanto en la ejecución desnuda como en la instrumentada. La instrumentación cuesta
alrededor de 1,6 veces el tiempo total de test en una máquina tranquila y es el
multiplicador menor; el resto es latencia de planificación. Por eso ninguno de los dos
plazos depende de --coverage: un límite sobre el que el bucle rápido y la puerta no se
ponen de acuerdo es uno que no puede reproducir la puerta.
asyncUtilTimeout se queda bien por debajo de testTimeout a propósito. Un elemento que
no va a llegar nunca debería perder la carrera, para que el fallo diga "Unable to find
an element with the text: …" y lo nombre, en vez de "Test timed out", que no nombra
nada.
Ninguno de los dos números es licencia para un spec que hace más trabajo del que sus
aserciones leen: montar cuarenta filas de tabla dos veces para demostrar un recuento
costaba unos dos segundos en rag/[id]/counts.integration.test.tsx antes de que su
fixture se recortara a tres.
Playwright arranca lo que la suite necesita: el frontend y un servidor de modelo de
prueba compatible con OpenAI (frontend/e2e/stub-model-server.ts) en
127.0.0.1:4010 por defecto. El backend y su base de datos tienen que estar ya
levantados — el propietario sembrado, el perfil de modelo y el agent publicado vienen de
agenticos cmd bootstrap.
Ambos puertos son configurables, para que la suite corra junto a otro checkout que ya
ocupa los valores por defecto — un bun run dev dejado arriba en el 3000, o un segundo
worktree. E2E_PORT mueve el frontend, E2E_STUB_MODEL_PORT el stub, y
playwright.config.ts deriva de ellos el baseURL, ambas webServer.url y los
PORT/E2E_STUB_MODEL_PORT de los servidores — así que a nada se le dice un puerto dos
veces. make test-e2e lee los tres (con E2E_BACKEND) y los imprime antes de empezar:
El stub es lo que permite a journey.spec.ts ejecutar un agent de punta a punta sin una
clave de provider: sirve la API de Chat Completions, streaming incluido, y un perfil de
modelo llega a él por el campo Endpoint. Devuelve el token que las instrucciones del
agent le dicen que diga —que es la aserción, ya que nada más podría poner ese token en la
respuesta— y devuelve el uso, así que el run se tarifa y la última aserción del recorrido
tiene un coste que encontrar. No autentica nada ni llama a herramientas; lo que no
demuestra es que responda un provider real.
El stub se ata al loopback, y el backend lo marca en 127.0.0.1:<port> a través de ese
perfil guardado — así que el backend tiene que compartir el loopback de la máquina. Ese
es el camino de uvicorn en el host que ejecuta CI; un backend dentro de un contenedor no
puede alcanzar el 127.0.0.1 del host, y mover el puerto no cambia eso.
Un e2e en rojo suele ser el fixture, no el producto¶
setup y seed son dependencias de proyecto de Playwright, así que un fallo en
cualquiera de los dos impide que lleguen a ejecutarse los proyectos que dependen de él.
El resumen dice entonces 1 failed, 7 passed y 17 did not run, lo que en un pull
request parece exactamente una funcionalidad rota — y no lo es: no se ejecutó ningún
spec de producto. Tres ramas pagaron cada una ese diagnóstico en un solo día
(#132), así que
frontend/e2e/fixture-reporter.ts imprime ahora un aviso diciéndolo, y bajo CI una
anotación de error de GitHub que se ve en la página de checks sin abrir un log.
Esperar a una fila no es esperar a la escritura¶
Un spec que crea algo a través de un diálogo no debe pulsar enviar y luego afirmar que la fila nueva está en pantalla. Esa forma estaba en seis sitios y se vio flakear en cuatro. Dos razones, y la segunda es la cara:
- La ventana entre que la mutación se resuelve y la lista se renderiza es real, y un
timeout de
expectmás largo solo hace que una carrera tarde más en fallar. - Un diálogo de Radix abierto saca del árbol de accesibilidad al resto de la página.
Mientras hay uno en pantalla,
getByRole("main"),getByRole("row")y todos los locators construidos sobre ellos resuelven a nada, así que la aserción expira conelement(s) not foundexista la fila o no — nombrando lo único que no puede ser la causa. Un create rechazado se veía idéntico a un refetch lento en cuatro ocasiones distintas.
submitDialog, en frontend/e2e/helpers.ts, es la vía: espera a la respuesta de la
propia escritura y afirma su estado (así que un rechazo se lee como
409 … already exists, en milisegundos), y después espera a que el diálogo se cierre,
que es la aplicación diciendo que ha terminado todo lo que hace alrededor de la
escritura.
Lo que a propósito no promete es que la fila esté ya renderizada, porque eso hoy no es cierto: al refetch de la lista se le responde a veces con la lista previa a la escritura aunque la fila esté confirmada y ambas capas del servidor la devuelvan (#230, en torno a una ejecución de cada ocho). Así que:
- Un paso de fixture le pregunta a la API, y sigue preguntando. Cada paso de
seed.setup.tsafirma a través de/api/…, porque su trabajo es que el fixture exista — y un paso de fixture que falla se lleva por delante todos los specs de producto. Después de una escritura pregunta sondeando (nowThere), nunca con una sola lectura. Eso empezó como un apaño: un 2xx de este backend significaba antes que la petición se había respondido y no que la escritura fuera legible, porque el commit corría en una dependencia que FastAPI desenrolla después de que la respuesta haya salido (#353). Eso está arreglado —el commit aterriza ahora antes que la respuesta— y el sondeo se queda de todos modos, porque un fixture es el sitio equivocado para descubrir que otra escritura es más lenta que su acuse, y porquenowThereimprime las filas que sí vio donde una sola lectura no imprime nada. La guardaalreadyTherecon la que abre cada paso es una sola lectura a propósito, ya que corre antes de la escritura. La única comprobación posterior a la escritura que leyó una sola vez costó 87 specs saltados tres veces en un día (#335). - Un spec de producto que trata del renderizado lo dice, y recarga primero si
necesita una lista de la que fiarse.
vault.spec.tstiene tres llamadas apage.reload()marcadas con#230; cuando ese issue se cierre, salen.
La base de datos de pruebas¶
Casi ningún test toca una base de datos real. El fixture client de
tests/conftest.py sobrescribe get_db_session con una sesión asíncrona mockeada
(AsyncMock) mediante el app.dependency_overrides de FastAPI, así que la suite corre
rápido y no necesita un contenedor de Postgres:
mock_db_session— unAsyncMockque hace deAsyncSession(execute,commit,rollback,close)- Los overrides se registran antes de cada test y se limpian después
- Afirma contra las llamadas del mock, o simula los valores de retorno de
execute(...)para el camino bajo prueba
Todo lo que hay bajo tests/integration/ es la excepción, y pide el fixture db de
tests/integration/conftest.py en vez de construirse un engine propio: ese fixture es
lo que pone el esquema en su sitio.
El esquema se construye una vez para todo el proceso, y los datos se reinician entre tests.
El fixture schema_url ejecuta create_all una sola vez. El fixture engine, de
alcance de función, le entrega después a cada test una base de datos vacía haciendo
TRUNCATE de todas las tablas de los modelos —y borrando cualquier tabla que un test
creara fuera de los modelos, una rag_<collection> en tiempo de ejecución o una sonda
de ordenación— en vez de reconstruir el esquema.
Antes hacía drop_all + create_all antes de cada test: unos 0,4 s de DDL que eran
casi por completo el tiempo de ejecución de una suite cuyas aserciones son microsegundos
de trabajo de Postgres. Construirlo una vez bajó tests/integration de unos 125 s a
unos 50 s (#215).
TRUNCATE en vez de un rollback de transacción, porque los tests de flujo de la API
confirman a través del get_db_session real y sus filas sobreviven a un rollback.
La base de datos que usa pertenece al proceso de pytest que la pidió:
<POSTGRES_DB>_p<pid>, creada cuando la sesión arranca y borrada cuando termina, fallos
incluidos.
Eso es lo que hace seguras dos ejecuciones a la vez —dos worktrees, o un worktree y un
make test, contra el único contenedor de Postgres— y no necesita que se pase nada en
la línea de comandos.
El nombre fue constante hasta #189. Como cada test borraba y recreaba el esquema en esa base de datos compartida, dos ejecuciones se pasaban el tiempo borrándose las tablas la una a la otra e informando de fallos que no eran de ninguna de las dos ramas.
La suite rechaza cualquier base de datos cuyo nombre no contenga test o ci
Borra tablas sin condiciones, así que esa guarda es lo único que hay entre ella y una base de datos de desarrollo.
La credencial se resuelve una vez, en tests/conftest.py, y todo lo demás la lee de
vuelta del objeto de settings.
Dos engines llegan a esa base de datos —el del fixture y el de la aplicación, construido
en tiempo de import en app/db/session.py— y un test que pregunta si una escritura es
visible necesita los dos.
Antes resolvían la contraseña por separado, con el fixture por defecto en postgres
donde app/core/config.py la deja vacía, y nada podía verlo mientras todos los tests se
conectaban a través del fixture.
El primer test que movió el engine de la aplicación no logró autenticarse en un checkout
sin backend/.env — es decir, en cualquier worktree de git, al no estar el archivo
versionado. Dos fallos contra un verde completo en todo lo demás, que se leían
exactamente como una regresión de rama
(#485).
La suite siembra ahora POSTGRES_PASSWORD=postgres antes de que se construya el objeto
de settings, y solo cuando ni el entorno ni un .env aportan una, para que una
contraseña real nunca se sustituya por la de por defecto.
app/core/config.py la sigue dejando vacía por defecto, que es lo que hace que un
.env ausente se anuncie en alembic check en vez de llegar a una base de datos con
una suposición.
La suite de migraciones tiene una tercera¶
tests/test_migrations.py aplica la cadena entera a una base de datos vacía y la
revierte hasta base, así que no puede usar ninguna de las dos anteriores: la de
integración ya tiene el esquema dentro (construido desde los modelos, que es otra
pregunta), y downgrade base contra la de la suite unitaria la vaciaría a media
ejecución. Recibe agenticos_migrations_test_p<pid>, creada antes de su primer test y
borrada tras el último, y a cada subproceso de alembic se le pasa ese nombre de forma
explícita en vez de heredar POSTGRES_DB.
Esa base de datos tenía antes que existir ya, y nada la creaba nunca, así que todos los
tests del módulo se saltaban en cada ejecución de CI que este proyecto ha tenido — una
build verde sobre las únicas aserciones de que downgrade() funciona
(#234). Ahora crea la suya, y el
salto que sobrevive significa solo lo que dice: no respondió ningún Postgres. En CI,
donde hay un contenedor de servicio declarado, eso es un fallo en su lugar — un
contenedor que no arrancó no es un entorno que no puede responder, y los dos son
indistinguibles en la salida de pytest.
make test-migrations sigue existiendo y sigue siendo lo que hay que ejecutar a mano
después de tocar alembic/versions/, pero apunta a lo que diga backend/.env, que en un
portátil es la base de datos con tu propio trabajo dentro. Prefiere
uv run pytest tests/test_migrations.py, que no puede alcanzarla.
Prefect, y por qué ningún test alcanza un servidor¶
Llamar a un @flow es una llamada de red, y la suite la apunta a ninguna parte.
Prefect resuelve sus propios ajustes desde backend/.env —su modelo de settings lleva
env_file=".env"— así que PREFECT_API_URL=http://localhost:4200/api, la línea que
make dev necesita, era también la dirección que intentaba alcanzar la llamada a
@flow de un test. Sin un servidor levantado eso es
RuntimeError: Failed to reach API at http://localhost:4200/api/ saliendo de un test que
mockeaba a todos sus colaboradores, y CI nunca lo vio: sin .env no hay URL, así que lo
que ejecutaba un portátil nunca fue lo que ejecutaba CI
(#536).
Por eso tests/conftest.py asigna PREFECT_API_URL vacía antes de que se importe
Prefect, junto al nombre y la contraseña de la base de datos de arriba y por la misma
razón.
Borrar la variable no valdría: una variable sin fijar deja que responda la fuente dotenv, y la fuente dotenv es la que tiene la URL.
Una asignación vacía le gana porque el modelo de settings de Prefect lleva
env_ignore_empty=False — que es la regla de Prefect y no la nuestra.
app/core/config.py lo fija al revés, así que la misma línea contra uno de nuestros
ajustes se descartaría y el .env respondería igualmente.
Prefect lee una URL vacía como ninguna URL y arranca un servidor temporal propio para la llamada, que es lo que CI ha hecho siempre. Así que la ejecución ya no depende de si hay un servidor de Prefect levantado, en ninguno de los dos sentidos.
El estado de ese servidor es una base de datos SQLite bajo PREFECT_HOME, y la suite
le da una propia. Si se deja en paz es ~/.prefect, así que una ejecución unitaria
escribiría sus flow runs en los datos de Prefect de quien desarrolla y, donde Prefect
corre en el host y no en Docker, en el archivo que un prefect server en marcha tiene
abierto. tests/conftest.py lo apunta a agenticos-prefect-test bajo el directorio
temporal del sistema, por la misma razón por la que el nombre de Postgres de arriba es
una base de datos de pruebas. Un directorio y no uno por proceso: lo que cuesta es
crearlo.
Crearlo es una migración, y la suite sube de 20 a 90 segundos el margen de Prefect para
arrancar ese servidor — como holgura, no porque 20 haya fallado nunca. Contra un
PREFECT_HOME aún sin escribir, el arranque entero tarda unos seis segundos en un
portátil y unos nueve en un contenedor de CI, frío en cada ejecución y nunca rojo con el
valor por defecto. El margen ampliado compra que el único paso cuyo coste nada acota aquí
—una migración en una máquina disputada, o un directorio temporal ya barrido— espere en
vez de tumbar una suite que una segunda ejecución pasaría.
tests/test_prefect_test_environment.py fija las cuatro propiedades.
Resumen¶
- Cuatro capas: unitaria, de integración, de API y E2E. Elige por lo que el test necesita que sea cierto, no por de qué trata.
- Los tests asíncronos usan anyio.
@pytest.mark.asynciono hace nada aquí. - Cubre el rechazo. Casi todo el valor de esta plataforma está en lo que rechaza.
- La capa de plataforma está al 100%, y añadirle un módulo significa editar dos
listas en
backend/pyproject.toml. - El orden se baraja en cada ejecución; reproduce un fallo con la semilla impresa antes de concluir nada sobre el cambio.