Testing¶
AdaptiveLearner's test discipline is enforced by make test
on every change. The strategy is a pyramid: unit at the base,
integration in the middle, E2E smoke at the top.
Test counts¶
| Layer | Tool |
|---|---|
| Backend unit + integration | pytest ^9 |
| Plugin tests (all plugins) | pytest ^9 |
| Frontend unit + integration | Vitest 4 |
| E2E smoke | Playwright |
| Dexie-mode release gate | Playwright |
The counts grow every release. To avoid duplicated numbers
that drift out of sync, this page does NOT hardcode a total.
docs/audits/current-coverage.md is the single canonical,
always-current source for test counts and coverage. The
plugins are assessment, the AI providers (anthropic / openai /
gemini / perplexity), session, tracking, tools, gamification,
anki, notebooklm, learning-repo, content-loader, and missions.
Test-driven development: the workflow¶
Everything below this section describes the infrastructure: which
runner to invoke, how to mock, where CI runs what. This section
describes the order of work. The binding norm lives in the
repository's rule catalogue (.claude/rules/tdd.md and the Tests
section of .claude/rules/coding-standards.md); this page explains
the workflow and walks through one real cycle. Where the two differ,
the rule catalogue wins.
The cycle is Red-Green-Refactor:
- RED - write a test that describes the wanted behaviour, and run it. It must fail, and it must fail for the expected reason.
- GREEN - write the minimal code that makes it pass. Nothing "for later".
- REFACTOR - clean up names, duplication, formatting. The tests stay green and are not touched.
When the obligation applies¶
Every change with behaviour: a new code path, a condition, a calculation, a validation, a mapping. Bug fixes are the strictest case: a fix without a first-failing test is not a proven fix. The reproduction test is written before the fix and stays in the repo as the regression pin afterwards.
It does not apply to pure renames, mechanical refactors already covered by existing tests (the suite staying green is the proof), formatting, configuration without logic, or documentation.
A real cycle from this repository¶
The example is the release helper scripts/bump_roadmap_header.py
with its suite backend/tests/test_bump_roadmap_header.py. It was
chosen because its red state is unambiguous and needs no domain
knowledge: the module under test did not exist yet, and the decisive
error case is a missing file.
What was to be built, in two sentences: docs/ROADMAP.md and
docs/backlog.md open with a dated "Current state" prose entry that
had gone stale for five releases in a row. The helper prepends the
released version as the new entry (summary seeded from the release
notes), demotes the previous entry into the prior chain, and must
refuse loudly when the release-notes file or the header anchor is
missing.
The test first. The fixture builds the real repo shape in
tmp_path (a backend/pyproject.toml carrying the canonical
version, both header files, one release-notes file), and the tests
drive the CLI entry point, not internal functions:
@pytest.fixture()
def fake_repo(tmp_path: Path) -> Path:
"""Build a minimal repo layout the script operates on."""
(tmp_path / "backend").mkdir()
(tmp_path / "backend" / "pyproject.toml").write_text(
'[tool.poetry]\nname = "adaptive_learner"\nversion = "2.7.0"\n',
encoding="utf-8",
)
docs = tmp_path / "docs"
docs.mkdir()
(docs / "ROADMAP.md").write_text(ROADMAP_TEMPLATE, encoding="utf-8")
(docs / "backlog.md").write_text(BACKLOG_TEMPLATE, encoding="utf-8")
releases = tmp_path / "changelog" / "releases"
releases.mkdir(parents=True)
(releases / "v2.7.0.md").write_text(CHANGELOG_BODY, encoding="utf-8")
return tmp_path
def test_bump_prepends_current_state_and_demotes_prior_when_stale(
fake_repo: Path,
) -> None:
exit_code = bump_roadmap_header.main(["--repo-root", str(fake_repo), "--date", "2026-07-30"])
assert exit_code == 0
roadmap = (fake_repo / "docs" / "ROADMAP.md").read_text(encoding="utf-8")
assert roadmap.count("Current state:") == 1
assert "Current state: **v2.7.0 (released 2026-07-30 - " in roadmap
assert (
"see changelog/releases/v2.7.0.md).** Recent prior: **v2.6.1 (released 2026-07-24"
in roadmap
)
assert "Recent prior: **v2.6.0" in roadmap
The error case the helper exists for: the release-notes file is missing, so the run must fail instead of writing a half-truth into the headers.
def test_bump_fails_when_changelog_missing(fake_repo: Path) -> None:
(fake_repo / "changelog" / "releases" / "v2.7.0.md").unlink()
exit_code = bump_roadmap_header.main(["--repo-root", str(fake_repo), "--date", "2026-07-30"])
assert exit_code == 1
The file holds six tests in total; the other four cover idempotence
at the canonical version, a missing header anchor, the seed-summary
extraction, and --dry-run writing nothing. Read the file itself for
the complete picture.
The red run. Before one line of implementation existed:
$ poetry run pytest tests/test_bump_roadmap_header.py -q
ERROR tests/test_bump_roadmap_header.py - FileNotFoundError: [Errno 2] No suc...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.09s
(The truncated line is pytest's own -q output, unedited.)
Why this is the most important step. A test that was never red proves nothing about what it checks. It can be green because it asks the wrong question, because it never reaches the code, or because its fixture contains nothing. This repository has paid for that lesson more than once:
- Five consecutive "fixed" backup releases (#49, #57, #64, #115,
#117) each shipped with passing unit tests, and none of them
produced a working export/import round-trip in the real app.
Green, and wrong: the tests asked a question the real data never
asked. That history is why the manual backup round-trip gate
exists (BACKUP-AKZEPTANZTEST in
.claude/rules/quality-checks.md). - The content availability oracle (#1816 / #1818) was green against
hand-built
{source, id}fixtures that encoded the author's own assumption about whatlistSetsreturns. Against the realContentSetEntryshape the assumption was false in API mode; module and tests were green and wrong together. That is the most common trap: building the fixture to fit the test instead of copying the real data shape into the fixture.
A note on the quality of a red: the collection error above is
acceptable only because the module under test did not exist at all.
For a brand-new file, "cannot import" is the expected reason. The
moment the module exists, every test must fail for its own reason:
the missing-changelog test above goes red through exit_code == 1,
not through an import error. A test that is red because of a typo in
an import has proven nothing.
The code that makes it green, in its first version. The core is an anchored replacement plus a fail-loud guard in the entry point:
ROADMAP_ANCHOR = re.compile(r"Current state: \*\*v(\d+\.\d+\.\d+) \(released ")
def bump_roadmap(text: str, version: str, date: str, summary: str) -> str | None:
"""Prepend the new Current-state entry; None when already current."""
match = ROADMAP_ANCHOR.search(text)
if match is None:
raise ValueError("ROADMAP.md: 'Current state: **vX.Y.Z (released' anchor not found")
if match.group(1) == version:
return None
new_entry = (
f"Current state: **v{version} (released {date} - {summary} "
f"see changelog/releases/v{version}.md).** "
f"Recent prior: **v{match.group(1)} (released "
)
return text.replace(match.group(0), new_entry, 1)
changelog_path = repo_root / "changelog" / "releases" / f"v{version}.md"
if not changelog_path.exists():
print(f"ERROR: {changelog_path} not found - draft the release notes first")
return 1
The green run:
The cleanup afterwards. In this cycle it was purely mechanical:
ruff format reformatted the new test file before the commit
("1 file reformatted"), no logic change, and the suite stayed green.
When a real refactor is needed (naming, extraction, deduplication),
the same contract holds: the tests are not touched and are green
before and after.
Running the cycle in this project¶
cd backend && poetry run pytest tests/test_<yourfile>.py -q # the tight loop
make test # the full gate before commit
The frontend equivalent of the tight loop is
cd frontend && bunx vitest run src/path/to/file.test.tsx. The
pre-commit hook runs the backend smoke suite on every commit either
way, and make test must be green after every change.
Backend pytest¶
make test-backend
cd backend && poetry run pytest -k "test_session" -v
cd backend && poetry run pytest --pdb
Tests live in backend/tests/. Fixtures in conftest.py
provide a fresh in-memory SQLite DB per test, the
TestClient, and a mocked plugin manager. Test isolation is
hard - ADAPTIVE_LEARNER_TEST=1 is set before any app.*
import.
Plugin tests¶
Each plugin has its own tests/ directory:
make test-plugins # all 13
make test-plugin-session # just one
cd plugins/adaptive-learner-plugin-session && poetry run pytest
Plugin tests don't load the FastAPI app - they exercise the
plugin's modules in isolation. Mock the pluggy.PluginManager
when testing hook firing.
Frontend Vitest¶
make test-frontend # runs Vitest from frontend/
cd frontend && bunx vitest # watch mode
cd frontend && bunx vitest run src/storage/ # one directory
Run Vitest from frontend/ (its config lives in
frontend/vite.config.ts), or via make test-frontend. From
the repo root the config is not found, the node environment
is used, and DOM-touching tests fail with
ReferenceError: document is not defined.
Tests live alongside the source: Component.test.tsx next to
Component.tsx. happy-dom is the environment; React 19 + RTL.
The i18n parity guard (11 languages), the theme-token parity
guard, and the design-token "no hardcoded colors" guard run as
Vitest tests inside this same suite.
Mock patterns¶
AI providers: mock global.fetch and assert on the URL,
headers, body:
beforeEach(() => {
global.fetch = vi.fn(async (input, init) => {
calls.push({url, method, body});
return new Response(JSON.stringify({content: [{type: "text", text: "hi"}]}), {status: 200});
});
});
fake-indexeddb: at the top of every Dexie test file:
import "fake-indexeddb/auto";
beforeEach(async () => {
await _resetDbForTests();
const {IDBFactory} = await import("fake-indexeddb");
(globalThis as unknown as {indexedDB: IDBFactory}).indexedDB = new IDBFactory();
});
Each test gets a fresh in-memory IndexedDB - no leakage.
api/client.ts mocks (legacy pages):
vi.mock("../api/client", async () => {
const actual = await vi.importActual<typeof import("../api/client")>("../api/client");
return {...actual, api: {...actual.api, users: {...actual.api.users, get: apiGetMock}}};
});
The page imports getStorage(), which delegates to
ApiStorage, which delegates to api.*. The mock cuts in at
the api.* layer and still fires through the storage stack.
Playwright E2E¶
cd e2e && npx playwright test
cd e2e && npx playwright test --ui # interactive
cd e2e && npx playwright test smoke/mobile-viewports.spec.ts
Smoke specs cover the critical user paths:
- Landing language picker + onboarding form
- Assessment 12 questions + radar render
- Session start + end + rate
- Settings language + API key
- Curriculum create
- Mobile viewports (iPhone SE, iPhone 14, Pixel 7, iPad)
Specs use data-testid selectors only - no brittle CSS
selectors. The smoke specs are NOT on the make test path;
they need a running app (make dev-bg first).
Beyond e2e/smoke/, the e2e/ tree holds three more spec
families:
e2e/dexie/- the Dexie-mode release gate. Builds the frontend withVITE_STORAGE_MODE=dexie(the GitHub-Pages shape, no backend) and walks every nav-reachable route; any error toast or page crash fails it. Run withmake test-dexie-smoke.e2e/visual/- visual-baseline regression specs.e2e/manual-automation/- Playwright automation of the manual test plan.
Coverage¶
Coverage is a report, not a merge gate, so it does not run on
PRs. The coverage.yml workflow runs nightly (and on demand);
download the artifacts:
Targets per .claude/rules/quality-checks.md:
- Services + business logic: 95% min
- API endpoints: 90% min
- Frontend components with logic: 85% min
- Hooks + utilities: 95% min
Overall: 85-95% project-wide.
Pre-commit¶
Hooks: ruff check (auto-fix), ruff format, trailing
whitespace, end-of-file fixer, check-yaml, check-json,
check-added-large-files, check-merge-conflict, frontend
ESLint, a plugin lockfile/pyproject pairing guard, and a
bundled-content stats validator. In the CI pre-commit job the
prettier-frontend and eslint hooks are skipped (the
Frontend Tests job runs ESLint with deps installed instead).
CI¶
CI splits into two tiers: correctness gates run on every PR (they must pass to merge), and the expensive or warn-only suites run on the night shift and at release time.
.github/workflows/ci.yml runs on push to develop / main
and on every PR (Python 3.12):
- Backend tests (pytest)
- Plugin tests (
make test-plugins, all 13 via the backend venv) - Frontend:
tsc --noEmit, ESLint (--max-warnings 0), circular-dependency check, Stylelint, Vitest,vite build,npm audit - Pre-commit hooks on all files
- Backend ruff + mypy + pip-audit
- Docs drift verifier (
verify_docs.py+ mkdocs-nav sync)
Test Impact Analysis (#615): on a PR only the impacted
tests run - vitest run --changed origin/<base> and
pytest --testmon. Push to develop / main, the nightly
runs, and release runs always run the FULL suite. The fallback
to the full suite is automatic (unresolvable base ref, or a
testmon cache miss).
Two more PR gates live in their own workflows:
complexity-check.yml- the complexity ratchet gate (make check-complexity-gate, radon for Python + ESLint complexity for TS). It is a baseline ratchet: it fails only on NEW or regressed offenders versus.complexity-baseline, so it blocks new complexity without forcing a sweep of pre-existing debt. The full warn-only complexity report runs nightly.cohesion-check.yml- the file-size guard (gate against.filesize-whitelist) plus two class-name gates: dead CSS class names (check-dead-classnames.pyagainst.dead-classnames-baseline) and the unstyled-className gate (--unstyled, a ratchet against.unstyled-classnames-baseline) - aclassNamewhose tokens are all dead blocks the PR. The companion folder-size guard runs locally viamake check-folder-size.visual-baseline-gate.yml- a PR that changes visual-critical paths (lesson components, exercise renderers, theme/CSS files) must carry the affected baseline screenshots in the same PR; escape labelvisual-baselines-unaffectedfor provably inert changes.testid-reference-gate.yml- if a PR removes or renames adata-testidthat an E2E spec statically references (on a high-user-visibility surface) without touching the spec, the gate fails (make check-testid-refs); escape labeltestid-refs-unaffected.docker-build-smoke.yml- build-only smoke of the production compose images (the launcher / install.sh path), path-filtered on PRs, plus onrelease/**, weekly, and on dispatch; locallymake docker-build-smoke.
Night shift / release (not on PRs):
dexie-smoke.yml- Dexie-mode E2E gate (daily + onrelease/**+ dispatch;make test-dexie-smokelocally)coverage.yml- coverage report (daily + dispatch)security-scan.yml- pip-audit / npm audit / bandit (weekly + onrelease/**+ dispatch; warn-only)content-stats.yml- content-stats drift vs a fresh content checkout (daily + dispatch)mutation-frontend.yml- Stryker mutation testing (nightly behind the repo variableENABLE_NIGHTLY_MUTATION+ dispatch; each run mutates one slice of the files so the run fits the job timeout); backend mutation testing uses mutmutwebkit-gate.yml- the real-WebKit engine layout gate (iOS/Safari bug classes the Chromium gates structurally cannot see), daily behind the repo variableENABLE_NIGHTLY_WEBKIT, always onrelease/**and on dispatchvisual-regression.yml- the visual baseline matrix (daily + dispatch;update_baselines=truere-renders the baselines in CI and uploads them as an artifact)visual-baseline-sync.yml- service workflow: renders the baselines in CI and pushes them as a commit onto the PR branch (labelrefresh-visual-baselines, or dispatch with a PR number) - image review before merge stays mandatory
.github/workflows/release-gate.yml runs on tag pushes:
verifies version pins are synced across all version-bearing
files (no drift), plugin lockfiles match, and regenerated
artifacts are up to date.
Manual test plan¶
What automation cannot cover (layout, readability, touch interaction, theme contrast) is checked by a manual checklist before every larger release: MANUAL-TESTPLAN.md.