RAGAPPv3 Testing
The authoritative testing policy and conventions live in
docs/engineering/testing.md. Read it before touching tests.
Policy (summary)
- New behavior ships with tests. For a bug fix, add a failing reproduction test, then make it pass.
- Feature gate / optional dependency removal: when removing a gate, scan for old assertions. See
docs/engineering/testing.md→ "Feature gate / optional dependency removal checklist". - Assert real behavior: backend → status + body + DB state change; frontend → callback args / DOM, not just "it rendered".
- Cover negative paths (403/422, cross-vault isolation, cascade deletes, error branches). Security-sensitive code has
*_adversarialcompanion tests. - No test theater — a test must exercise what its name claims. See "Test must exercise what its name claims" below.
- Verify regression tests are non-vacuous: before committing, stash or revert ONLY the source fix (leave the new test in place), run it, and confirm it fails with the original bug. Restore the fix and confirm it passes. A test that passes on both fixed and unfixed code is not a regression guard — it is theater.
- Dataclass-default audit (retry/retry-like fields): When changing the default of a dataclass field that is observable by tests (e.g.,
TaskItem.attempt,EnrichmentTaskItem.attempt), search tests for assertions that depend on the old default and update them; add a regression test that would fail under the old default if the new default changes behavior. Do NOT "grep for hardcodedattempt=N" by literal — the termattemptis generic across the codebase (also used byfailed_attempts,retry_count, etc.). Instead, enumerate the affected dataclass field by its full import path. - Post-sleep shutdown re-check (retry/backoff paths): When a retry or backoff path sleeps before requeuing work (e.g.,
asyncio.sleep,time.sleep, polling intervals), the production code MUST check the shutdown flag both before the sleep and after the sleep. The post-sleep re-check is required becausestop()(or any shutdown signal) may be called while the worker is waiting on the sleep. Canonical existing example:BackgroundProcessor._enrichment_worker_loopre-checksshutdown_eventafterasyncio.sleep; the same discipline must be applied to any other retry path (notably_handle_failurein the same module). Tests that exercise a retry path SHOULD stop the processor/worker mid-sleep and assert queued work is dropped (not requeued) after the sleep completes. - Test must exercise what its name claims. A test named
test_step_back_*that accessescall_args[0][0](positional) but the production code calls with kwargs (messages=messages) is testing the LAST recorded call (which may be from a different code path) — and silently passing. Usecall_args_listand find the right call by signature, not justcall_args.
Backend (pytest + unittest)
unittest.TestCase/IsolatedAsyncioTestCaserun under pytest;asyncio_mode = "auto"(no marker needed).conftest.pysets test env and clearsapp.*modules.- Route tests use the
SimpleConnectionPool+app.dependency_overridesharness — canonical examplebackend/tests/test_tags_routes.py: tempdir →init_db→run_migrations; overrideget_db/get_vector_store(AsyncMock) /get_current_active_user/csrf_protect; restore in teardown.- When the endpoint uses
Depends(get_evaluate_policy), you MUST also overrideget_evaluate_policyin setUp (FastAPI resolves all dependencies even if not called, so the real one acquires a real DB connection). Pattern intest_api_routes.py::TestDocumentsEndpoints.setUp(line 486-489).
- When the endpoint uses
- Seed rows in FK order; verify cascades by deleting the parent (FKs are ON).
- CSRF on mutating endpoints: most state-mutating routes now depend on
csrf_protect. Route tests don't reconstruct the cookie/header double-submit —conftest.pyauto-bypasses CSRF via the pytest-onlyRAGAPP_CSRF_TEST_BYPASSenv flag (honoured bysecurity.csrf_protectonly whenPYTEST_CURRENT_TESTis also set). Classification is automatic: a test module whose source mentions "csrf" is left to exercise the real validator (don't rely on the bypass there); every other module gets the bypass, which works for both the sharedapp.mainapp and tests that build their ownFastAPI(). Filename alone is not used for classification. To test real CSRF enforcement, ensure the module source references csrf; to just hit a protected endpoint, do nothing. - Per-file
lancedb/pyarrow/unstructuredstubs are load-bearing for CI, not redundant boilerplate — CI installsrequirements-ci.txt, which omits those packages (seeci-compatibility-audit). Removing a stub can break collection in CI even though it passes locally with the full deps installed. - CI pins Python 3.11. Local 3.14+ fails some tests with
RuntimeError: There is no current event loop— a local artifact, not a regression. Avoid manualasyncio.get_event_loop()in new tests.
Authz-aware test fixture setup
When you modify endpoint authorization (adding caller-org intersection checks,
role-based access, assigned-org validation, etc.), the new authz preconditions
can break tests in any file that exercises that endpoint — not just the
files in your PR diff. CI runs the full pytest tests/ suite (~3918 tests),
so a test file you never touched can fail if its fixture doesn't seed the data
the new check requires.
Before pushing authz changes:
- Grep for ALL test files that call the modified endpoint:
grep -rl "users.*organizations\|users.*groups" backend/tests/ - For each file found, verify its fixture seeds the relational data the new
check needs (e.g.,
org_membersrows for caller-org intersection checks). - Run the full test suite locally (from
backend/):pytest tests/ -q --tb=short
Common fixture gaps when adding org-scoped authz:
- Test creates users + orgs but no
org_membersentries → caller-org intersection check sees empty sets → 403 where test expects 200. - Test fixture places data in a row that a per-test INSERT also targets →
UNIQUE(org_id, user_id)constraint violation. Always check for per-test INSERTs in the same table before placing fixture data.
Real example (PR #240): test_user_org_roles.py had its own setup_db
fixture that created users and orgs but no org_members. The caller-org
intersection check added to update_user_organizations caused failures in
TestPerOrgRoleMemberships and TestLegacyOrgIdsFormat (the third class,
TestDeleteUserOwnerGuard, uses DELETE and was unaffected). Fix: seed
ADMIN_ID into all orgs and TARGET_ID into a non-conflicting org in setup_db.
Conftest.py shared fixtures (post-PR #215)
The conftest.py now has 3 autouse fixtures plus 1 session-scoped fixture:
_bypass_csrf_for_csrf_naive_tests(autouse) — CSRF bypass for CSRF-naive modules_reset_rate_limiter(autouse) — resets in-memory rate limiter_reset_db_pool(autouse, since #215) — closes the singleton SQLite pool between tests_cache_bcrypt_hash_for_test_passwords(session-scoped, since #215) — caches bcrypt hash for common test password 'pass123'
Patterns to follow when adding a new autouse fixture in conftest.py:
- Place it AFTER the existing fixtures, BEFORE
pytest_configure - Use
try/except (ImportError, AttributeError)guards around imports (production modules may not be importable during early collection) - Use
monkeypatch.setattrfor cleanup (or yield + restore in finally) - Match the existing pattern of doing cleanup BOTH before and after yield (defensive on both ends)
Critical pattern for monkey-patching:
Patch the UNDERLYING method, not the wrapper function, when test modules do
from app.services.auth_service import hash_password at module level. Direct
imports capture the reference at import time, bypassing module-level patches.
The pwd_context.hash patch pattern in _cache_bcrypt_hash_for_test_passwords
is the canonical example.
Frontend (Vitest + RTL + jsdom)
Vitest, not
bun:test. Ignore any bun guidance.
- Config in
frontend/vite.config.ts;*.test.tsx;src/test/setup.tsmockslocalStorage/confirm/scrollTo. - jsdom mock patterns (full snippets in
ci-compatibility-audit/references/frontend-testing-gotchas.md): wrap<Link>components inMemoryRouter; mock@/components/ui/select(Radix can't open in jsdom); mock@tanstack/react-virtual'suseVirtualizerto render all rows;vi.mockfactories can't close over outer vars (await import("react")).
Fake Timers & Async Loop Testing
Tests for polling loops, reconnect logic, and interval-driven behavior require fake timers to avoid real wall-clock waits. This repo's Vitest patterns:
Use
vi.useFakeTimers()+vi.advanceTimersByTimeAsync()for async loop assertions. Fake timers let you advance time deterministically without waiting real seconds. Always callvi.useRealTimers()inafterEach.Prefer
vi.waitFor(Vitest) over@testing-library/reactwaitForwhen fake timers are active. RTL'swaitForuses real timers internally and may hang or timeout when fake timers are installed. Vitest'svi.waitForis fake-timer-aware and integrates correctly withvi.advanceTimersByTimeAsync.
afterEach(() => {
vi.useRealTimers();
cleanup();
});
- Canonical pattern for testing an async reconnect loop (e.g., WebSocket/SSE with polling backoff):
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { MyReconnectingComponent } from "./MyReconnectingComponent";
const connectMock = vi.fn();
describe("reconnect loop with fake timers", () => {
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("retries connection on failure", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
render(<MyReconnectingComponent />);
// Initial render triggers connection attempt
// Advance past the retry interval to trigger the retry
await vi.advanceTimersByTimeAsync(3000);
// Assert the retry was attempted
expect(connectMock).toHaveBeenCalledTimes(2);
});
});
- Mock sequencing for JWT refresh then reconnect: When testing a hook or
component that reconnects after a 401 token_expired response, sequence the
fetch error, then the refresh mock, then the
getJwtAccessTokenMockreturn values. Canonical pattern fromuseWikiEventStream.test.ts:
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
it("reconnects with the refreshed token after a 401 token_expired response", async () => {
vi.useFakeTimers();
fetchMock
.mockResolvedValueOnce(errorResponse(401, "token_expired"))
.mockResolvedValue(controllableSse().response);
refreshAccessTokenMock.mockResolvedValue("refreshed-jwt-token");
getJwtAccessTokenMock
.mockReturnValueOnce("test-jwt-token")
.mockReturnValue("refreshed-jwt-token");
renderHook(() => useWikiEventStream(42, vi.fn()));
// Wait for the initial fetch and the refresh.
await vi.waitFor(() => expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1));
// Advance past RECONNECT_BASE_MS (1000 ms) so the backoff timer fires.
await vi.advanceTimersByTimeAsync(1100);
// The hook returns "error" after successful refresh, which triggers reconnect.
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
// Verify the second fetch uses the refreshed token.
const [, init] = fetchMock.mock.calls[1];
expect(init.headers.Authorization).toBe("Bearer refreshed-jwt-token");
});
Key points about this pattern:
fetchMockresolves the first call as 401 token_expired, the second call as successrefreshAccessTokenMock.mockResolvedValue("refreshed-jwt-token")provides the new tokengetJwtAccessTokenMock.mockReturnValueOnce(...).mockReturnValue(...)sequences old→new tokenvi.advanceTimersByTimeAsync(1100)triggers the reconnect backoff timerThe final assertion checks that the SECOND fetch used the refreshed token in the Authorization header
vi.useRealTimers()gotcha: Between the test body andafterEach, there is a window where realsetTimeout/setIntervalcan fire. If a component started an interval with fake timers that fires during teardown, wrap the interval-clearing in a try/finally or usevi.useRealTimers()BEFOREcleanup()in afterEach. The recommendedafterEachtemplate above handles this correctly by callingvi.useRealTimers()first.Do not use
vi.advanceTimersByTime(sync) for async code paths. Always prefer the async variantvi.advanceTimersByTimeAsync. The sync variant cannot flush microtasks (Promises) and produces false test passes.
Source-inspection test pattern
Some backend tests open Python source files as strings and regex-match for
structural invariants — e.g., "every StreamingResponse call must include
X-Accel-Buffering", or "every route file exports a router object". This
pattern appears in backend/tests/test_path_prefix.py and similar files.
When to use it:
- Enforcing crossutting structural invariants that are hard to exercise behaviorally (e.g., "all streaming responses must set a header").
- Checking that boilerplate or security-sensitive patterns are not omitted.
- When a behavioral test would require an integration setup disproportionate to the risk being tested.
When NOT to use it:
- As a substitute for behavioral tests when a behavioral test is straightforward.
- For logic correctness — source inspection cannot catch a header present but set to the wrong value.
- Where refactoring (e.g., renaming a function) would silently break the test without breaking production behavior.
Tradeoff: Fast and easy to write; fragile to non-behavioral refactoring. Always prefer behavioral unit tests when practical. When using source inspection, add a comment explaining why a behavioral test is not used.
Running
Since PR #215 (issue #209), the Backend job runs the full pytest tests/
suite (3918 tests, ~18m on CI Linux, ~3-5m locally). The job timeout is
60m. The full suite is the source of truth — there's no separate "narrow
subset" anymore. Run your changed area's tests locally first for fast
feedback (pytest -q tests/<file>); then run the full suite before
pushing. Use ci-compatibility-audit for the exact CI-mirror commands.
See docs/engineering/testing.md for full detail.