Skip to content

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 client fixture auto-provisions whoever an X-Dev-User header names; a stricter fixture pins a known [alice, bob] set so an unknown user fails 401.
  • An sse_client fixture commits after each request and flushes the after_commit listeners 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:

  1. Factor the decision into a pure function and unit-test that. The board's frontend/src/board/boardLayout.ts resolveStatusDrop(activeId, overId, tasks) is the worked example — it maps a drop to a status change and is covered exhaustively in boardLayout.test.ts, independent of any DOM.
  2. Verify the gesture wiring (sensor activation, collision, the PATCH) in Playwright, where layout is real. See frontend/e2e/board.spec.ts and canvas-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.