Testing¶
Four layers, one runner, and a coverage gate that fails the build below 100% on the platform layer.
The short version of what to run: the tests that cover the change while you are writing, and the suites once before you push.
Running tests¶
While writing, run what covers the change; the suite is the pre-push gate
A file answers in about a second where the suite takes a minute and a half, and says the same thing about the change.
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
These stay serial on purpose: spawning worker processes to run one file costs
more than the file does. The whole-suite targets — make test, make test-fast,
make test-integration, make test-cov — run across workers (pytest -n auto --maxprocesses 4),
which roughly halves the I/O-bound integration suite; pytest-cov combines the
per-worker data, so the 100% gate is unchanged. The cap is four because the unit
suite is import-bound — every worker imports the app once — and gains nothing past
that, while an uncapped auto on a many-core laptop is slower than serial, all of
it worker startup (#520).
Every run is shuffled, and a test that passed yesterday may have been depending on the order
pytest-randomly prints the seed in the header
(Using --randomly-seed=1697040112). Replay that seed serially to get
the same order back - -n auto does not fix which worker runs what.
An order-dependent test — one that passes only because something before it left state behind — is the classic "green on my laptop, red in CI", and a suite that always runs in collection order never asks the question. CI asks it in a fresh order every run.
uv run pytest tests/ -q --randomly-seed=1697040112 # that order again, serially
uv run pytest tests/ -q -p no:randomly # collection order, while bisecting
The seed is chosen once by the controller and handed to each xdist worker, so -n auto
collects one order rather than four. It does not fix which worker runs what: make test
leaves xdist on its default --dist load, which hands each test to whichever worker is
free. So a failure that depended on what shared a worker — the InterfaceError #571
found is one — comes back by replaying the seed serially, as above, and not by
re-running make test. The plugin also reseeds random identically before every test,
so anything using it for uniqueness is unique within a test and repeats across them.
Until #571 the plugin was documented but not installed, -p no:randomly was a silent
no-op, and nothing had ever exercised the claim.
Once, before pushing — make check runs all of it, in this order:
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
About five minutes serial, against CI's twelve in parallel — the backend test
job the long pole there, which #520 is cutting. 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, and a skipped required check still lets a merge
through. Locally there is no equivalent: check runs everything.
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.
Two ways to push something that has not been verified
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. And pytest without
uv run picks up whatever interpreter is on the path rather than the pinned
3.12.
Test structure¶
Four layers, and which one a test belongs to is decided by what it needs rather than by what it is about.
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/ is grouped by what is being asked, not by route module: some files
take one endpoint (test_admin_ratings_window.py), and test_platform_routes.py
sweeps a whole family at once, which is why agents.py has no file of its own.
Look for the question before looking for the path.
| Layer | Where | For |
|---|---|---|
| Unit | tests/test_*.py |
One module. Repositories are mocked; the service under test never is |
| API | tests/api/, and some at the top level |
The route: its gate, its status code, what reaches the service |
| Integration | tests/integration/ |
What only a database answers - an ORDER BY, a cascade, a unique constraint, a query that is really tenant-scoped |
| E2E | frontend/e2e/ |
Journeys crossing the whole system - see Frontend Tests |
There is no tests/unit/ directory: a unit test is a test_*.py at the top of
tests/. The layer is what a test needs, not where it sits, and the top level
holds plenty that drives the app through an AsyncClient of its own -
test_rag_document_listing.py, test_oauth_signin_exchange.py,
test_security_headers.py. Looking only under tests/api/ for existing route
coverage will miss them.
One exception, and it is at the top level rather than in integration/.
tests/test_migrations.py cycles the whole Alembic chain against a real database,
which it creates and drops itself under a name of its own - because
downgrade base drops every table, and inheriting POSTGRES_DB once emptied a
developer's working database. It is collected by an ordinary pytest tests/. It
is not in integration/ because it does not use that package's db fixture at
all: it runs alembic in subprocesses.
Async - anyio, not pytest-asyncio¶
or @pytest.mark.anyio on the test, which tests/api/test_users.py does where
only some of a file is async. Either works; the module-level form is the habit
here because most files are async throughout.
@pytest.mark.asyncio does not work here, and there is no asyncio_mode to make it
The suite runs on anyio. An unmarked async def fails at collection with
a message about the framework rather than about the test, so it reads as a
broken environment on the way in.
An unmarked async def is not a silent pass: pytest 9 fails it at collection
with "async def functions are not natively supported" and lists the plugins
that would fix it. The anyio_backend fixture pins asyncio, because that is
what uvicorn runs.
Key fixtures (tests/conftest.py)¶
Five. None of them is a test_user or a signed-in client, and that is the
point: an authenticated caller is a dependency override, so a test says which
authority it is exercising rather than inheriting one. tests/api/test_users.py
builds an auth_client of its own out of exactly those overrides - a local
fixture for the file that needs one, not a shared one every file inherits.
| Fixture | |
|---|---|
anyio_backend |
Pins asyncio, and nothing names it - anyio asks for it |
client |
httpx.AsyncClient over ASGITransport(app=app) — not Starlette's TestClient. Overrides get_db_session and get_redis, and clears app.dependency_overrides afterwards |
mock_db_session |
An AsyncMock. Its info is a real dict, because that is where spawn_after_commit queues work |
mock_redis |
A MagicMock(spec=RedisClient) with the async methods stubbed |
api_key_headers |
The service-to-service header, for a route behind ValidAPIKey |
tests/integration/conftest.py adds the ones that touch a database. The package
refuses any database whose name contains neither test nor ci, and empties
every table between tests. It skips itself when none is reachable only outside
CI: with CI set it raises instead, because a skip and a Postgres service that
failed to start read identically in pytest's output and only one of them is
acceptable on a runner.
| Fixture | |
|---|---|
db |
A real AsyncSession - what almost every integration test takes |
engine |
The AsyncEngine behind it, for a test that needs a session db cannot be: more than one - a race, a concurrent write, two transactions that have to interleave, where one AsyncSession shared across them is not a second connection but a corrupted one - or one the code under test makes for itself, which is how the RAG tests hand PgVectorStore its own async_sessionmaker. Eighteen files take it |
database_url, schema_url |
Session-scoped, and the reason the two above are safe: they name the throwaway database and create its schema once |
Writing tests¶
Name the behaviour, not the function, so that a failure says what broke:
test_a_grant_widens_access_without_promoting_the_member, not test_resolve.
A service test¶
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())
The repository is mocked and the service is not. A test that mocks the thing it is testing passes when the implementation is deleted.
An API test¶
The caller is an override, which is what makes the refusal testable:
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 does this by sweeping, rather than one
assertion per route: the gate a route carries is a table, and a table is walked
rather than restated. It walks the platform prefixes - /agents, /runs,
/approvals, /spend, /stats, /skills and the rest of _PLATFORM_PREFIXES -
which is why most of those have no file of their own, and why /auth,
/organizations and /users still need their own: the sweep passes over them.
An integration test¶
Only for what a mocked session cannot answer, which is usually an ordering, a
constraint or a cascade. tests/integration/test_message_order.py is the shape:
a turn writes its question and its answer inside one transaction, so both rows
carry the same created_at to the microsecond and the tie is broken by a column
rather than by the planner.
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"]
The bug there was Postgres, and a mocked session would have passed against the schema that had no tiebreak at all.
What is worth a test here¶
Cover the refusal
Most of this platform's value is in what it refuses, so the refusal is the case that has to exist:
- a cross-tenant read — including one where the caller owns the row;
- an ungranted scope;
- a budget checked before the model request, and recorded even when the run fails;
- a spec refused at publish rather than at run time;
- no plaintext secret in any response, log line or audit entry.
.claude/rules/testing.md and the backend-tests skill carry the rest - the
traps, the worked examples and the history behind each. This page is the shape of
the suite; neither repeats the other.
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.
Two deadlines, both sized for a loaded machine¶
A render-heavy spec is not slow because it is badly written; it is slow because
several thousand of them share ten cores with whatever else is running.
testTimeout in vitest.config.ts is 15s and Testing Library's
asyncUtilTimeout in vitest.setup.ts is 5s, both raised from defaults that
only hold on an idle machine.
The numbers come from running the whole suite four ways (#862):
| Slowest single test | Bare | Under --coverage |
|---|---|---|
| Ten idle cores | 1.7s | 2.9s |
| 32 busy loops beside it | 5.4s | 6.1s |
Under that load the old 5s default failed three tests a run — a different three
each time, because which files share a worker is decided on timing, and in the bare
run as well as the instrumented one. Instrumentation costs about 1.6x of total test
time on a quiet machine and is the smaller multiplier; scheduling latency is the
rest. That is why neither deadline is conditional on --coverage: a limit the fast
loop and the gate disagree about is one that cannot reproduce the gate.
asyncUtilTimeout stays well under testTimeout on purpose. An element that is
never coming should lose the race, so the failure says "Unable to find an element
with the text: …" and names it, rather than "Test timed out" and names nothing.
Neither number is a licence for a spec that does more work than its assertions need:
mounting forty table rows twice to prove a count cost about two seconds in
rag/[id]/counts.integration.test.tsx before its fixture was cut to three.
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. - A product spec that made the write waits the same way the fixtures do.
nowListedandnowMatchingine2e/helpers.tsarenowTheremoved out ofseed.setup.ts, so there is one primitive rather than two (#162). It splits one failure into two: a failure in the poll is a write that never landed, a failure in the assertion after it is a page that did not draw a row the API was serving.journey.spec.tsends by proving a run was priced — five things had to happen, and a baretoBeVisible()on the row said only that one of them had not.
Every wait says what it was waiting for¶
expect(locator).toBeVisible() fails with element(s) not found, which names
the one thing that cannot be the cause. Playwright takes a message as the second
argument, and the suite's long journeys use it:
await expect(
row,
`run ${id} is priced at ${cost}, but Activity draws no row for ${modelLabel}`,
).toBeVisible();
Four specs have flaked with the anonymous form, and each cost a diagnosis that
started from nothing (#130,
#132,
#154,
#162). The message costs a
line and is the whole of the difference between "it flaked again" and a
diagnosis. When one fails in CI anyway, playwright-results beside
playwright-report is the screenshot, the video, the trace and Playwright's own
error-context.md.
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 schema is built once for the whole process, and the data reset between tests.
The schema_url fixture runs create_all a single time. The function-scoped
engine fixture then hands each test an empty database by TRUNCATE-ing every model
table — and dropping any table a test created outside the models, a runtime
rag_<collection> or an ordering probe — rather than rebuilding the schema.
It used to drop_all + create_all before every test: ~0.4s of DDL that was very
nearly the entire runtime of a suite whose assertions are microseconds of Postgres
work. Building it once cut tests/integration from ~125s to ~50s
(#215).
TRUNCATE rather than a transaction rollback, because the API-flow tests commit
through the real get_db_session and their rows outlive a rollback.
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. Because each test dropped and recreated the schema on that shared database, two runs spent their time dropping each other's tables and reporting failures that belonged to neither branch.
The suite refuses any database whose name does not contain test or ci
It drops tables unconditionally, so that 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 test 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.
Prefect, and why no test reaches a server¶
Calling a @flow is a network call, and the suite points it at nowhere. Prefect
resolves its own settings from backend/.env — its settings model carries
env_file=".env" — so PREFECT_API_URL=http://localhost:4200/api, the line make dev
needs, was also the address a test's @flow call tried to reach. Without a server up
that is RuntimeError: Failed to reach API at http://localhost:4200/api/ out of a test
that mocked every collaborator it has, and CI never saw it: with no .env there is no
URL, so what a laptop ran was never what CI ran
(#536).
tests/conftest.py therefore assigns PREFECT_API_URL empty before Prefect is
imported, next to the database name and password above and for the same reason.
Deleting the variable would not do: an unset variable leaves the dotenv source to answer, and the dotenv source is what holds the URL.
An empty assignment outranks it because Prefect's settings model carries
env_ignore_empty=False — which is Prefect's rule and not ours. app/core/config.py
sets it the other way, so the same line against one of our settings would be
discarded and the .env would answer anyway.
Prefect reads an empty URL as no URL and starts a temporary server of its own for the call, which is what CI has always done. So the run no longer depends on whether a Prefect server happens to be up, in either direction.
That server's state is a SQLite database under PREFECT_HOME, and the suite gives it
one of its own. Left alone it is ~/.prefect, so a unit run would write its flow runs
into a developer's Prefect data and, where Prefect runs on the host rather than in
Docker, into the file a running prefect server has open. tests/conftest.py points it
at agenticos-prefect-test under the system temporary directory, for the same reason
the Postgres name above is a test database. One directory rather than one per process:
what costs is creating it.
Creating it is a migration, and the suite raises Prefect's 20-second allowance for
starting that server to 90 — as headroom, not because 20 has been seen to fail.
Against a PREFECT_HOME nothing has written yet the whole start takes about six seconds
on a laptop and about nine on a CI container, which is cold on every run and has never
been red on the default. What the raised allowance buys is that the one step whose cost
nothing here bounds — a migration on a contended machine, or a temporary directory that
has been swept — waits rather than failing a suite that a second run would pass.
tests/test_prefect_test_environment.py pins all four properties.
Recap¶
- Four layers: unit, integration, API, E2E. Pick by what the test needs to be true, not by what it is about.
- Async tests use anyio.
@pytest.mark.asynciodoes nothing here. - Cover the refusal. Most of this platform's value is in what it refuses.
- The platform layer is at 100%, and adding a module to it means editing two
lists in
backend/pyproject.toml. - The order is shuffled every run; replay a failure with its printed seed before concluding anything about the change.