Testing¶
Tests are written in the same change as the code they cover, and a change isn't done until they pass. The backend suite is pytest, runs against a real PostgreSQL, and is gated on coverage.
Layout¶
Tests live in backend/tests/, organised by feature area — for example:
| File | Covers |
|---|---|
test_auth.py, test_entra_id.py |
the auth providers and token validation |
test_task_endpoints.py, test_dependency_endpoints.py |
CRUD and validation |
test_concurrency.py |
the field-aware 409 logic |
test_cycle.py, test_delete_protection.py |
graph invariants and ADR 0008 deletion rules |
test_events_sse.py, test_events_broadcaster.py, test_events_project_scope.py |
the real-time stack |
test_start_date.py, test_due_date.py |
date derivation |
test_undo.py |
undo-group reversal |
test_migrations.py |
a downgrade base → upgrade head round-trip |
test_telemetry.py |
the no-op-when-unset wiring |
Fixtures and the test database¶
conftest.py sets up a real tasktree_test database. The shape:
- Migrations run once per session. The engine fixture downgrades to base and upgrades to head a single time, not per test.
- Each test runs in a rolled-back transaction. Tests are wrapped in an outer transaction using savepoint-based nesting, so a test can
commit()(which the SSE path needs) without dissolving the isolation — the outer transaction rolls back at the end, leaving the database pristine for the next test, with no re-migration cost. - Auth in tests uses the local-dev provider. The
clientfixture auto-provisions whoever anX-Dev-Userheader names; a stricter fixture pins a known[alice, bob]set so an unknown user fails401. - An
sse_clientfixture commits after each request and flushes theafter_commitlisteners so event publication actually fires, and resets the broadcaster's subscriber set between tests.
The coverage gate¶
Coverage is enforced on every run via pytest options in backend/pyproject.toml: the suite fails below 80% branch coverage. Actual coverage runs well above the floor.
The frozen test clock¶
Date-sensitive tests pin "now" to a fixed sentinel date (ADR 0033) so derivations are deterministic. Pure functions take an explicit now argument; the frontend freezes its clock to the same reference date so fixtures read consistently across both stacks.
Running them¶
From backend/:
uv run pytest -v # everything
uv run pytest --cov=app --cov-report=term-missing # with coverage detail
uv run pytest -k concurrency # by keyword
uv run pytest tests/test_auth.py -v # one file
The host-vs-container database gotcha¶
conftest.py defaults TEST_DATABASE_URL to the Docker-network hostname postgres, which resolves inside the compose network. If you run pytest on your host machine (not in the container), that hostname won't resolve — set it to localhost instead:
TEST_DATABASE_URL=postgresql+psycopg://tasktree:tasktree@localhost:5432/tasktree_test
(assuming the compose file forwards 5432). This trips up almost everyone once.
End-to-end tests (Playwright)¶
frontend/e2e/ holds Playwright specs. Playwright starts its own Vite dev
server on port 5174 and expects a local-dev backend reachable at
http://127.0.0.1:8000 — the specs authenticate with the X-Dev-User
header, which the entra-id provider ignores (it would 401). The quickest way
to get one is the second-stack overlay, which forces local-dev:
docker compose -f docker-compose.yml -f docker-compose.worktree.yml up backend postgres
cd frontend && npm run test:e2e # or test:e2e:smoke for the @smoke subset
The default .env files are entra-id, so plain docker compose up will make
the specs 401 — that's the usual first stumble.
Interaction-heavy features (drag, pointer, keyboard sensors)¶
Drag-and-drop and other pointer-gesture logic cannot be unit-tested in
jsdom/happy-dom: getBoundingClientRect returns zeros there, so @dnd-kit /
React Flow collision detection never resolves a target. The convention:
- Factor the decision into a pure function and unit-test that. The board's
frontend/src/board/boardLayout.tsresolveStatusDrop(activeId, overId, tasks)is the worked example — it maps a drop to a status change and is covered exhaustively inboardLayout.test.ts, independent of any DOM. - Verify the gesture wiring (sensor activation, collision, the PATCH) in
Playwright, where layout is real. See
frontend/e2e/board.spec.tsandcanvas-group-drag.spec.ts.
Specs that exercise a gesture end-to-end are tagged @smoke
(npm run test:e2e:smoke).
Running e2e in CI (planned)¶
E2E is not yet wired into CI (ADR 0042). The intended shape of a gating @smoke
job: a Postgres service (as the backend job already declares), the backend
started with AUTH_PROVIDER=local-dev on :8000 after alembic upgrade head,
a bootstrapped project so getDefaultProjectId resolves (a fresh DB has
none), npx playwright install --with-deps chromium, then
npm run test:e2e:smoke. It was left as a watched fast-follow rather than
enabled blind, since a gating job that can't be validated without iterating on
CI risks a flaky red gate for everyone.
Manual notification testing (the dummy-user harness)¶
Comments and @-mention notifications
(ADR 0049,
ADR 0050)
deliver strictly per-recipient, so testing the @-tag → notify loop needs a
second user. On staging that's awkward — Entra auth has no ?as= switching
and a users row only exists after a real sign-in. backend/scripts/notify_test.py
(ADR 0051)
gives you a synthetic teammate, Robbie Testbot, and refuses to run when
ENVIRONMENT=production.
Run it inside the backend container locally, or from the staging App Service SSH
console (where DATABASE_URL is already in the environment):
python scripts/notify_test.py seed # add the dummy + a "🔔 Notification sandbox" task
python scripts/notify_test.py mention you@example.com # dummy @-mentions you → a durable notification
python scripts/notify_test.py inbox # the dummy's OWN inbox — did your @-tags land?
python scripts/notify_test.py remove # tear the dummy + everything it created back out
After seed, Robbie Testbot appears in the @-mention picker like a real
member — tag it from a comment, then inbox confirms the notification reached
it. mention posts a comment as the dummy that tags you; reload TaskTree and
the bell badge + inbox row are there.
The live toast doesn't fire from mention. The script runs in a separate
process from the single-worker in-process broadcaster
(ADR 0036),
so it only writes the durable notification — the recipient sees it on the next
page load / reconnect, not as a live pop-in. To exercise the live per-user
push, mention across two local-dev tabs (e.g. ?as=alice mentions ?as=bob
with both open). The full rationale, including the alternatives that were
deferred, is in
ADR 0051.