Testing Guide¶
Running Tests¶
While writing, run what covers the change — a file answers in about a second where the suite takes a minute and a half, and says the same thing:
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
Once, before pushing — make check runs all of it, in this order:
make lint # ruff, ruff format, ty, eslint, prettier, tsc, and the two 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
About five minutes serial, against CI's seven in parallel. The equality is
maintained rather than asserted: the workflow calls these targets rather than
repeating their commands, and tests/test_ci_parity.py fails if a gating job
grows a step make check does not run. It has drifted four times — see
Commands for what check leaves out and why.
CI may run fewer jobs than check does, and that is not drift. test,
test-frontend and e2e are skipped on a pull request whose changed paths cannot
affect them — a docs-only change runs none of the three, a backend-only change runs
no frontend suite. What decides is scripts/ci_changed_scope.py, it errs towards
running, and Branches
has the rule and why a skipped required check still lets a merge through. Locally
there is no equivalent: check runs everything.
make test-fast skips coverage, which makes it the wrong last word before a push:
the gate is most of what these commands are for. pytest without uv run picks up
whatever interpreter is on the path rather than the pinned 3.12.
Test Structure¶
tests/
├── conftest.py # Shared fixtures
├── api/ # API endpoint tests
│ ├── test_health.py
│ └── test_auth.py
├── unit/ # Unit tests (services, utils)
│ └── test_services.py
└── integration/ # Integration tests
└── test_db.py
Key Fixtures (conftest.py)¶
# Database session for tests
@pytest.fixture
async def db_session():
async with async_session() as session:
yield session
await session.rollback()
# Test client
@pytest.fixture
def client():
return TestClient(app)
# Authenticated client
@pytest.fixture
async def auth_client(client, test_user):
token = create_access_token(test_user.id)
client.headers["Authorization"] = f"Bearer {token}"
return client
Writing Tests¶
API Endpoint Test¶
def test_health_check(client):
response = client.get("/api/v1/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
Service Test¶
async def test_create_item(db_session):
service = ItemService(db_session)
item = await service.create(ItemCreate(name="Test"))
assert item.name == "Test"
Test with Authentication¶
def test_protected_endpoint(auth_client):
response = auth_client.get("/api/v1/users/me")
assert response.status_code == 200
Frontend Tests¶
Run these from frontend/. At the repository root vitest finds no configuration,
reports well over a hundred phantom failures and leaves a stray node_modules/.
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 measures no coverage, so it cannot answer whether the
test-frontend job will pass: the gate wants 100% lines, statements and functions and
97.5% branches over src/{app/api,lib,stores,hooks} and most of src/components.
Playwright starts what the suite needs: the frontend, and an OpenAI-compatible
stub model server (frontend/e2e/stub-model-server.ts) on 127.0.0.1:4010
by default. The backend and its database have to be up already — the seeded
owner, model profile and published agent come from agenticos cmd bootstrap.
Both ports are configurable, so the suite runs beside another checkout that
already holds the defaults — a bun run dev left up on 3000, or a second
worktree. E2E_PORT moves the frontend, E2E_STUB_MODEL_PORT the stub, and
playwright.config.ts derives baseURL, both webServer.urls and the servers'
PORT/E2E_STUB_MODEL_PORT from them — so nothing is told a port twice.
make test-e2e reads all three (with E2E_BACKEND) and prints them before it
starts:
The stub is what lets journey.spec.ts run an agent end to end without a
provider key: it serves the Chat Completions API, streaming included, and a model
profile reaches it through the Endpoint field. It echoes back the token the
agent's instructions tell it to say — which is the assertion, since nothing else
could put that token in the reply — and returns usage, so the run is priced and
the journey's last assertion has a cost to find. It authenticates nothing and
calls no tools; what it does not prove is that a real provider answers.
The stub binds loopback, and the backend dials it at 127.0.0.1:<port> through
that stored profile — so the backend has to share the host's loopback. That is
the host-uvicorn path CI runs; a backend in a container cannot reach the host's
127.0.0.1, and moving the port does not change that.
A red e2e is often the fixture, not the product¶
setup and seed are Playwright project dependencies, so a failure in either
one stops the projects that depend on it from running at all. The summary then
reads 1 failed, 7 passed and 17 did not run, which on a pull request looks
exactly like a broken feature — and is not: no product spec ran. Three
branches each paid a diagnosis for that in one day
(#132), so
frontend/e2e/fixture-reporter.ts now prints a banner saying so, and under CI a
GitHub error annotation that shows on the checks page without opening a log.
Waiting for a row is not waiting for the write¶
A spec that creates something through a dialog must not click submit and then assert the new row is on screen. That shape sat at six sites and was seen to flake at four. Two reasons, and the second is the expensive one:
- The window between the mutation resolving and the list rendering is real, and a
longer
expecttimeout only makes a race slower to fail. - An open Radix dialog takes the rest of the page out of the accessibility
tree. While one is on screen,
getByRole("main"),getByRole("row")and every locator built on them resolve to nothing, so the assertion times out withelement(s) not foundwhether or not the row exists — naming the one thing that cannot be the cause. A refused create looked identical to a slow refetch for four separate occurrences.
submitDialog in frontend/e2e/helpers.ts is the way through: it waits for the
write's own response and asserts its status (so a refusal reads
409 … already exists, in milliseconds), then waits for the dialog to close —
which is the app saying it has finished everything it does around the write.
What it deliberately does not promise is that the row is now rendered, because that is not currently true: the list's refetch is sometimes answered with the pre-write list even though the row is committed and both server layers return it (#230, about one run in eight). So:
- A fixture step asks the API, and keeps asking. Every step of
seed.setup.tsasserts through/api/…, because its job is that the fixture exists — and a fixture step that fails takes every product spec with it. After a write it asks by polling (nowThere), never with a single read. That began as a workaround: a 2xx from this backend used to mean the request was answered and not that the write was readable, because the commit ran in a dependency FastAPI unwinds after the response has gone out (#353). That is fixed — the commit now lands before the answer does — and the polling stays anyway, because a fixture is the wrong place to discover that some other write is slower than its acknowledgement, and becausenowThereprints the rows it did see where a single read prints nothing. ThealreadyThereguard each step opens with is a single read on purpose, since it runs before the write. The one post-write check that read once cost 87 skipped specs three times in a day (#335). - A product spec that is about the rendering says so, and reloads first if it
needs a list it can trust.
vault.spec.tshas threepage.reload()calls marked#230; when that issue closes, they come out.
Test Database¶
Most tests don't hit a real database. The client fixture in tests/conftest.py overrides
get_db_session with a mocked async session (AsyncMock) via FastAPI's
app.dependency_overrides, so the suite runs fast and needs no Postgres container:
mock_db_session— anAsyncMockstanding in forAsyncSession(execute,commit,rollback,close)- Overrides are registered before each test and cleared afterwards
- Assert against the mock's calls, or stub
execute(...)return values for the path under test
Everything under tests/integration/ is the exception, and it asks for the db
fixture from tests/integration/conftest.py rather than building an engine of its
own — that fixture is what puts the schema in place.
The database it uses belongs to the pytest process that asked for it:
<POSTGRES_DB>_p<pid>, created when the session starts and dropped when it ends,
failure included. That is what makes two runs at once safe — two worktrees, or a
worktree and a make test, against the one Postgres container — and it needs nothing
passed on the command line. The name was constant until #189,
and since the fixture rebuilds the schema before every test, two runs spent their time
dropping each other's tables and reporting failures that belonged to neither branch.
The suite still refuses any database whose name does not contain test or ci: it
drops tables unconditionally, so the guard is the only thing between it and a
development database.
The credential is resolved once, in tests/conftest.py, and everything reads it
back off the settings object. Two engines reach that database — the fixture's, and
the application's, built at import time in app/db/session.py — and a test asking
whether a write is visible needs both. They used to resolve the password separately,
the fixture defaulting to postgres where app/core/config.py defaults to empty, and
nothing could see it while every test connected through the fixture. The first one to
drive the application's engine failed to authenticate on a checkout with no
backend/.env — which is every git worktree, the file being untracked — two
failures against a full green everywhere else, reading exactly like a branch
regression (#485). The suite now
seeds POSTGRES_PASSWORD=postgres before the settings object is built, and only when
neither the environment nor a .env supplies one, so a real password is never
replaced by the default. app/core/config.py still defaults it to empty, which is
what makes a missing .env announce itself in alembic check rather than reaching a
database with a guess.
The migration suite has a third one¶
tests/test_migrations.py applies the whole chain to an empty database and rolls it
back to base, so it can use neither of the above: the integration database already
has the schema in it (built from the models, which is a different question), and
downgrade base against the unit suite's would empty it mid-run. It gets
agenticos_migrations_test_p<pid>, created before its first test and dropped after
its last, and every alembic subprocess is passed that name explicitly rather than
inheriting POSTGRES_DB.
That database used to have to exist already, and nothing ever created it, so every
test in the module skipped on every CI run this project had — a green build over the
only assertions that downgrade() works at all
(#234). It now creates its own,
and the surviving skip means only what it says: no Postgres answered. In CI,
where a service container is declared, that is a failure instead — a container that
did not start is not an environment that cannot answer, and the two are
indistinguishable in pytest's output.
make test-migrations still exists and is still the one to run by hand after
touching alembic/versions/, but it points at whatever backend/.env says, which
on a laptop is the database with your own work in it. Prefer
uv run pytest tests/test_migrations.py, which cannot reach it.