18 KiB
Testing Strategy & Verification Plan
Status: Living document. Started 2026-05-30 on branch
test/backend-coverage-foundation(base:dev). Scope: Backend (apps/backend, Phases 1–6) and frontend (apps/frontend, vitest — §8). Gating is a localpre-pushhook, not PR CI. Why this exists: We shipped a build break that no automation caught, users report "Ollama doesn't work" and "resume won't render," and we had no evidence-based read on whether our tests are real or theater. This doc is the resumable record of the assessment and the plan.
1. TL;DR
- A real backend test suite already exists: 192 tests,
pytest+pytest-asyncio+httpx. We do not need a new framework. - When run: 191 pass, 1 fails, 54% line coverage. The one failure (
test_health_returns_degraded) is a stale test, not a product bug — and the fact it sat red proves the suite is never run by automation. - Root cause of "the build broke and nobody caught it": there is no PR-gating CI. The only workflow is
docker-publish.yml(build+push on merge tomain). Nothing runspytest,tsc,next build, or lint before merge. - The tests we have cluster on the deterministic algorithmic core (diff engine, schemas) and mock away the entire I/O surface (DB, LLM, parser, Playwright). So coverage is real where it exists but absent exactly where users get hurt.
- Two test types are needed and are different things: deterministic tests (plumbing correctness, LLM mocked, run on every change) and evals (LLM output quality, real/recorded LLM, run on demand/nightly). We have the first kind only for the core, and zero of the second.
2. Current-state assessment (evidence)
Run command (no pyproject.toml change — ephemeral coverage plugin):
cd apps/backend
uv run --with pytest-cov pytest -q --cov=app --cov-report=term-missing
# 192 tests · 191 passed · 1 FAILED · 54% coverage · ~6s
2.1 The one failure is the canary
tests/integration/test_health_api.py::test_health_returns_degraded expects GET /health to report "degraded" when the LLM is unhealthy. But app/routers/health.py was refactored so /health is a pure liveness probe that never calls the LLM (return HealthResponse(status="healthy")); readiness now lives at GET /status. The test was never updated. Its sibling test_health_returns_healthy passes for the wrong reason — it mocks check_llm_health, which the endpoint no longer calls, so the mock is dead and the assertion is hollow.
Takeaway: a green checkmark here meant nothing, and a red one went unseen. That is the exact failure mode behind the production incidents.
2.2 Coverage map (what's real vs absent)
| Module | Cover | Read |
|---|---|---|
services/improver.py (diff engine) |
84% | ✅ Genuinely strong — path resolution, apply/verify diffs, skill gating |
schemas/models.py |
88% | ✅ Real |
services/refiner.py |
72% | ✅ Decent |
routers/config.py |
53% | 🟡 Contract-level only |
llm.py |
47% | 🟡 Pure helpers tested; real request + check_llm_health + Ollama paths NOT |
routers/enrichment.py |
38% | 🟡 Regenerate matching tested; rest mocked |
config_cache.py |
38% | 🔴 |
database.py |
34% | 🔴 Real DB not exercised at this baseline — integration tests mocked db (changed in Phases 4 & 7) |
services/cover_letter.py |
26% | 🔴 Untested |
services/parser.py |
20% | 🔴 Upload→markdown→JSON untested; even the pure date-restore is uncovered |
pdf.py (render) |
20% | 🔴 The "resume won't render" class |
routers/resumes.py |
18% | 🔴 Biggest file (1,796 LOC): tailor + PDF + CRUD — almost entirely untested |
2.3 The structural truth about the integration tests (at the 192-test baseline)
At the 192-test baseline, every tests/integration/* test patched app.routers.<x>.db and the LLM/parse calls. They verified status codes, request validation, response shape, and router branch logic (e.g. API-key masking, regenerate fallback matching) — valuable contract tests. They did not prove the database persists, Playwright renders, markitdown parses, or any provider (Ollama included) actually responds. (Phases 4 & 7 below later added real-SQLite isolated_db persistence + pipeline/tracker integration tests.)
3. The framework decision
Keep pytest + pytest-asyncio + httpx. It is correctly configured (asyncio_mode=auto, strict markers, unit/service/integration markers). Add, incrementally:
| Need | Tool | Why |
|---|---|---|
| Coverage as a tracked number | pytest-cov |
Quantify gaps & deltas; stop guessing |
Real llm.py against a fake provider (incl. Ollama) |
respx (or pytest-httpx) |
Mock at the HTTP transport so our actual routing/normalization code runs — the only way to regression-test "Ollama doesn't work" |
| PDF render proof | Playwright (already a dep) | One smoke test → real PDF bytes from the print route |
| Prompt/skill quality | In-repo eval harness | Golden fixtures + structural scorers + optional LLM-as-judge |
| Push gate (local, replaces PR CI) | pre-push git hook (.githooks/) |
Runs backend suite + locale parity, blocks red pushes; no PR-triggered CI — see §5 Phase 6 |
3.1 Deterministic tests vs evals (the key distinction)
- Deterministic test — LLM mocked, asserts code behavior given a known response. Fast, runs on every change. Answers "is the plumbing correct?"
- Eval — real (or recorded) LLM call scored against a rubric. Non-deterministic, costs money/time, runs nightly/on-demand, never in the PR gate. Answers "did this prompt change make the output better?"
You cannot answer "does my prompt change help?" with a deterministic test. You need evals. Conversely you should never block a PR on a non-deterministic eval. Both layers, kept separate.
3.2 The prompt chain ("skills") and how each stage is tested
upload → parse_resume_to_json
→ extract_job_keywords
→ generate_skill_target_plan → verify_skill_target_plan (verify = pure, deterministic gate)
→ generate_resume_diffs [strategy: keywords | nudge | full]
→ apply_diffs (pure)
→ render_resume_pdf
For each stage:
- Deterministic layer — structural invariants that hold regardless of wording: valid JSON/schema, no fabricated employers/dates (truthfulness rules), every section preserved, JD keywords actually present in output, diff paths resolve. Most "the prompt change broke something" regressions are caught here, for free.
- Eval layer — golden
(resume, job_description)fixtures → run the stage with a real model → score with a rubric (heuristic + LLM-as-judge). Tracks quality over time and across prompt edits.
4. What "verify our recent work" means here
Three concrete mechanisms, applied to every change in this initiative:
- Coverage delta. Each batch reports before/after coverage for the modules it touches. Numbers, not vibes.
- Anti-theater check. For new tests on critical logic, confirm the test fails when the code is broken (a quick manual mutation). This is the antidote to the
test_health_returns_healthy"passes for the wrong reason" trap. - Regression tests that pin recent PRs. The first new coverage deliberately locks in behavior we recently shipped, so "our recent work" gains a safety net:
_normalize_api_base— the/v1/v1duplicate-path dedup and OpenAI preserve-as-is (issue #751).resolve_api_key— the security rule thatollama/openai_compatiblemust not fall back to the envLLM_API_KEY(so a paid key can't leak to a local server).get_model_name—ollama_chat/prefix and OpenRouter nested prefixes.- Empty-extracted-text rejection on upload (
resumes.py:546, PR #794). restore_dates_from_markdown— months survive LLM parsing.
5. Phased roadmap
Legend: ✅ done · 🚧 in progress · ⬜ planned
Phase 1 — Foundation + cheap deterministic coverage (PR #820 → dev) ✅ COMPLETE
- ✅ Audit + this document
- ✅ Make the suite green (fixed stale
healthtests; liveness vs readiness) - ✅
llm.pyprovider/Ollama pure-function regression tests (tests/unit/test_llm_providers.py) - ✅
parser.pypure tests (date restoration) (tests/unit/test_parser.py) + empty-text rejection (tests/integration/test_upload_api.py) - ✅ Real-SQLite
database.pyCRUD tests (tests/unit/test_database.py) - ✅ Verify: 192 → 265 tests, 1 silent failure → 0, coverage 54% → 58% (database 34→96%, parser 20→72%, llm 47→55%, health now meaningful). Anti-theater mutation check passed.
Phase 2 — Transport contract tests (LLM/Ollama) ✅ COMPLETE (tests/integration/test_llm_contract.py, 8 tests)
- ✅
respx-backed tests: realcomplete/complete_json/check_llm_healthagainst a fake Ollama + OpenAI-compatible HTTP server (base-URL handling #751, JSON extraction over the wire, thinking-tag stripping, health error-code mapping + secret scrubbing). Findings: litellm 1.86 defaults to an aiohttp transport respx can't see → tests setdisable_aiohttp_transport; Ollama makes two calls (/api/showprobe +/api/chat).llm.py55% → 74%.
Phase 3 — Render safety net ✅ COMPLETE (tests/integration/test_pdf_render.py, 11 tests)
- ✅ Real headless-Chromium render of a self-contained
data:URL → asserts genuine%PDFbytes; pure-helper tests (format/margins); connection-refused →PDFRenderErrormapping. Render tests skip cleanly without Chromium.pdf.py20% → 54%.
Phase 4 — End-to-end pipeline ✅ COMPLETE (tests/integration/test_pipeline_e2e.py, 5 tests)
- ✅ Real routers + real temp DB (
isolated_db), every LLM boundary mocked: upload → jobs → fetch and the preview→confirm tailoring handshake. Asserts real persisted state (master invariant,parent_idlinkage,improvementsrecord).resumes.py18% → 53%.
Phase 5 — Eval harness (structural + LLM-as-judge) ✅ COMPLETE (tests/evals/, 31 scorer tests + 1 gated judge)
- ✅ Pure structural scorers (
sections_preserved,no_fabricated_employers,jd_keywords_present,is_valid_resume,personal_info_unchanged) + golden fixtures, each proven on good AND bad inputs. - ✅ LLM-as-judge marked
@pytest.mark.eval, uses the developer's own configured key, excluded from the default run (addopts -m "not eval"); run on demand withuv run pytest -m eval. Skips cleanly with no key.
Phase 6 — Local pre-push gate (replaces PR CI) ✅ COMPLETE (.githooks/pre-push)
- ✅ A version-controlled
pre-pushhook runs the backend suite + a node-free locale-parity check and blocks the push on red. Activate per-clone withgit config core.hooksPath .githooks; bypass withgit push --no-verify. See.githooks/README.md. - ✅ We deliberately avoid a GitHub Actions PR gate — the repo gets a high volume of external contributor PRs; PR-triggered CI would run on every one (and run untrusted code). The local hook keeps
dev/maingreen for the maintainer's own pushes without that cost. - ⬜ (Optional, future) a Node-based
tsc/next buildcheck — deferred due to nvm-in-hook fragility; the pure-Python locale-parity guard already covers the known i18n break.
Phase 7 — SQLite persistence + tracker + encrypted-keys coverage (PRs #841 + #843 → main) ✅ COMPLETE
- ✅ The persistence layer moved from TinyDB to SQLite (async SQLAlchemy); the
conftest.py::isolated_dbfixture now swaps a disposable temp-file SQLite DB (not TinyDB) across all router modules, andtests/unit/test_database.pyexercises real SQLite CRUD (incl.TestApplications). - ✅ New tracker coverage:
tests/integration/test_applications_api.py(CRUD, column grouping, detail tolerance for a deleted resume, bulk move/delete) andtests/integration/test_tracker_autocreate.py(confirming a tailoring auto-creates anappliedcard). - ✅ Encrypted per-provider API keys:
tests/unit/test_crypto.py(Fernet encrypt/decrypt round-trip + masking). - ✅
/statusgraceful degradation (#843):tests/integration/test_health_api.pyexpanded — each check isolated, so a single failing probe yields 200 with partial/degraded state instead of 500. - ✅ Verify: default
uv run pytestcount is now ~444 (was ~320).respxstill mocks the HTTP transport forllm.py.
Result after Phases 1–7
192 → ~444 deterministic tests (+ 1 opt-in LLM-judge eval), 0 failures. Phases 1–5 were built via parallel subagents (one per phase, strict file ownership) using the dispatching-parallel-agents skill; Phase 7 followed the TinyDB→SQLite migration (PRs #841 + #843).
6. How to run
cd apps/backend
# Full deterministic suite (LLM-judge evals are auto-excluded via addopts -m "not eval")
uv run pytest
# Coverage (ephemeral plugin, no pyproject change)
uv run --with pytest-cov pytest -q --cov=app --cov-report=term-missing
# Prompt-quality evals on demand — structural scorers always run; the LLM-judge
# runs only when an LLM key is configured (uses the developer's own key), else skips
uv run pytest -m eval
# One module
uv run pytest tests/unit/test_parser.py -q
uv run pytestis unaffected by the project's nvm/npm constraints — it's Python-only. Frontendtsc/build/lint are run separately and are out of scope for this backend phase.
7. Decisions log
| Date | Decision | Rationale |
|---|---|---|
| 2026-05-30 | Base this initiative on dev, not main. Sync dev←main, branch off dev, merge work back to dev. |
User directive — batch this important work on dev before it reaches main. |
| 2026-05-30 | No CI workflow yet (tests only). | User directive. CI is the highest-ROI fix but is a separate, explicit decision (and .github/workflows/ is change-controlled). |
| 2026-05-30 | Eval layer = structural + LLM-as-judge, judge uses the developer-provided LLM key, skipped when absent. | User directive — the developer (usually the maintainer) supplies the key, so real-LLM scoring is acceptable when configured. |
| 2026-05-30 | Keep pytest; add respx, pytest-cov, Playwright smoke, eval harness. |
Existing framework is correct; fill gaps rather than replace. |
| 2026-05-30 | Gate pushes with a local pre-push hook, NOT GitHub Actions on PRs. |
Maintainer gets a high volume of external PRs; PR-triggered CI would run on all of them (incl. untrusted code). A local hook keeps dev/main green for the maintainer's own pushes — backend suite + node-free locale parity — without that cost. .githooks/ + core.hooksPath. |
8. Frontend test suite
apps/frontend uses vitest + Testing Library (jsdom) — run npm run test (or ./node_modules/.bin/vitest run). The same rigor as the backend was applied: assess what existed (a green 65-test suite over download-utils + two components), then cover the highest-value untested logic.
Added (apps/frontend/tests/):
i18n-utils.test.ts— thet()engine (getNestedValuedot-path + missing-key fallback,applyParamssubstitution).i18n-locale-parity.test.ts— in-suite guard for the build break: everymessages/*.jsonmust structurally matchen.json(mirrorsscripts/check_locale_parity.py). Verified anti-theater (adding a key toen.jsonfails all four locales).keyword-matcher.test.ts— JD↔resume keyword extract/segment/match-stats.section-helpers.test.ts— section ordering, custom-section IDs, localize-only-untouched-defaults.html-sanitizer.test.ts— the DOMPurify XSS whitelist (strong/em/u/a).api-client.test.ts—lib/api/clientURL resolution + timeout/AbortError (fetchstubbed).
Net: 65 → 117 frontend tests, all green. The pre-push gate runs this suite when Node is available; a full tsc/next build gate remains future work (nvm-in-hook fragility).
9. Open questions / future
- ✅
Frontend locale-parity test— done (i18n-locale-parity.test.ts+ the hook'sscripts/check_locale_parity.py). - Decide coverage floors per module once the I/O surface is broadly covered (avoid a single global % that hides gaps).
- A Node-aware
tsc/next buildgate (catches TS errors beyond locale drift) — deferred; needs reliable node-in-hook. - If GitHub Actions is ever reconsidered, run it on push to
dev/mainonly (not on PRs).
10. Agentic end-to-end monitor (on-demand, report-only)
Above the deterministic suites and the local pre-push gate sits an agentic E2E monitor — an opt-in, on-demand harness that drives the real running app (master resume → 3–4 tailored variations → PDFs), captures a durable evidence bundle (logs + every intermediate JSON + PDFs), and has a Claude Code skill judge it across three runtime jobs: output quality, flow/render integrity, and provider reality. It is a report, never a gate — it informs, never blocks a push, and is never wired into CI.
- Design:
docs/superpowers/specs/2026-06-01-agentic-e2e-monitor-design.md; plan:docs/superpowers/plans/2026-06-01-agentic-e2e-monitor.md; harness + how-to:apps/backend/e2e_monitor/README.md. - OSS-safe: harness deps are an optional extra (
uv sync --extra dev --extra e2e-monitor), every move is gated behindRM_E2E_MONITOR=1+ a configured key, and the runnable skill is gitignored (its source is the committede2e_monitor/AGENT_PLAYBOOK.md). The dev's real SQLite DB is never touched (isolatedDATA_DIR). - Run:
cd apps/backend && RM_E2E_MONITOR=1 uv run python -m e2e_monitor sweep, thenbash e2e_monitor/install_skill.shand invoke themonitor-e2eskill for the report.