Testen¶
AdaptiveLearners Test-Disziplin wird durch make test bei
jeder Änderung erzwungen. Die Strategie ist eine Pyramide:
Unit-Tests an der Basis, Integration in der Mitte, E2E-Smoke
oben.
Test-Zahlen¶
| Schicht | Werkzeug |
|---|---|
| Backend-Unit + -Integration | pytest ^9 |
| Plugin-Tests (alle Plugins) | pytest ^9 |
| Frontend-Unit + -Integration | Vitest 4 |
| E2E-Smoke | Playwright |
| Dexie-Modus-Release-Gate | Playwright |
Die Zahlen wachsen mit jedem Release. Um duplizierte Zahlen zu
vermeiden, die auseinanderdriften, hält diese Seite KEINE
Gesamtzahl fest. docs/audits/current-coverage.md ist die
einzige kanonische, stets aktuelle Quelle für Test-Zahlen und
Coverage. Die Plugins sind assessment, die KI-Anbieter
(anthropic / openai / gemini / perplexity), session, tracking,
tools, gamification, anki, notebooklm, learning-repo,
content-loader und missions.
Testgetriebene Entwicklung: der Ablauf¶
Alles unterhalb dieses Abschnitts beschreibt die Infrastruktur:
welcher Runner, wie gemockt wird, wo CI was ausführt. Dieser
Abschnitt beschreibt die Reihenfolge der Arbeit. Die bindende Norm
liegt im Regelkatalog des Repositories (.claude/rules/tdd.md und
der Tests-Abschnitt von .claude/rules/coding-standards.md); diese
Seite erklärt den Ablauf und geht einen echten Zyklus durch. Wo
beide abweichen, gilt der Regelkatalog.
Der Zyklus ist Rot-Grün-Refaktorieren:
- ROT - einen Test schreiben, der das gewünschte Verhalten beschreibt, und ihn ausführen. Er muss fehlschlagen, und zwar aus dem erwarteten Grund.
- GRÜN - den minimalen Code schreiben, der ihn bestehen lässt. Nichts "für später".
- REFAKTORIEREN - Namen, Duplikate, Formatierung aufräumen. Die Tests bleiben grün und werden nicht angefasst.
Wann die Verpflichtung greift¶
Jede Änderung mit Verhalten: ein neuer Codepfad, eine Bedingung, eine Berechnung, eine Validierung, ein Mapping. Fehlerbehebungen sind der strengste Fall: eine Behebung ohne zuerst fehlschlagenden Test ist nicht belegt. Der Reproduktionstest wird VOR der Behebung geschrieben und bleibt danach als Regressionsanker im Repo.
Sie greift nicht bei reinen Umbenennungen, mechanischen Refactorings mit bestehender Testabdeckung (die grün bleibende Suite ist der Beleg), Formatierung, Konfiguration ohne Logik oder Dokumentation.
Ein echter Zyklus aus diesem Repository¶
Das Beispiel ist der Release-Helfer scripts/bump_roadmap_header.py
mit seiner Suite backend/tests/test_bump_roadmap_header.py.
Gewählt, weil sein roter Zustand eindeutig ist und keine Fachlichkeit
voraussetzt: das getestete Modul existierte noch nicht, und der
entscheidende Fehlerfall ist eine fehlende Datei.
Was gebaut werden sollte, in zwei Sätzen: docs/ROADMAP.md und
docs/backlog.md beginnen mit einem datierten "Current state"-
Prosaeintrag, der fünf Releases in Folge veraltet war. Der Helfer
stellt die veröffentlichte Version als neuen Eintrag voran (Zusammen-
fassung aus den Release-Notes), stuft den vorherigen Eintrag in die
Kette zurück und muss laut verweigern, wenn die Release-Notes-Datei
oder der Header-Anker fehlt.
Der Test zuerst. Die Vorrichtung baut die echte Repo-Form in
tmp_path (ein backend/pyproject.toml mit der kanonischen Version,
beide Header-Dateien, eine Release-Notes-Datei), und die Tests fahren
den CLI-Einstieg, keine internen Funktionen:
@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
Der Fehlerfall, für den der Helfer existiert: die Release-Notes-Datei fehlt, der Lauf muss scheitern, statt eine Halbwahrheit in die Header zu schreiben.
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
Die Datei enthält insgesamt sechs Tests; die anderen vier decken
Idempotenz bei kanonischer Version, einen fehlenden Header-Anker, die
Zusammenfassungs-Extraktion und --dry-run ohne Schreibzugriff ab.
Für das vollständige Bild die Datei selbst lesen.
Der rote Lauf. Bevor eine Zeile Implementierung existierte:
$ 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
(Die abgeschnittene Zeile ist pytests eigene -q-Ausgabe,
unverändert.)
Warum das der wichtigste Schritt ist. Ein Test, der nie rot war, belegt nicht, dass er etwas prüft. Er kann grün sein, weil er die falsche Frage stellt, weil er den Code nie erreicht oder weil seine Vorrichtung nichts enthält. Dieses Repository hat dafür mehrfach bezahlt:
- Fünf aufeinanderfolgende "behobene" Backup-Releases (#49, #57,
#64, #115, #117) erschienen jeweils mit grünen Unit-Tests, und
keines davon lieferte einen funktionierenden Export/Import-
Roundtrip in der echten App. Grün, und falsch: die Tests stellten
eine Frage, die die echten Daten nie stellten. Diese Geschichte
ist der Grund für das manuelle Backup-Roundtrip-Gate
(BACKUP-AKZEPTANZTEST in
.claude/rules/quality-checks.md). - Das Content-Verfügbarkeits-Orakel (#1816 / #1818) war grün gegen
handgebaute
{source, id}-Vorrichtungen, die die Annahme des Autors überlistSetskodierten. Gegen die echteContentSetEntry-Form war die Annahme im API-Modus falsch; Modul und Tests waren gemeinsam grün und falsch. Das ist die häufigste Falle: die Vorrichtung so bauen, dass sie zum Test passt, statt die echte Datenform in die Vorrichtung zu kopieren.
Eine Anmerkung zur Qualität eines Rots: der Sammelfehler oben ist nur
akzeptabel, weil das getestete Modul überhaupt nicht existierte. Für
eine brandneue Datei ist "kann nicht importieren" der erwartete
Grund. Sobald das Modul existiert, muss jeder Test aus seinem eigenen
Grund scheitern: der Test zur fehlenden Release-Notes-Datei oben wird
über exit_code == 1 rot, nicht über einen Importfehler. Ein Test,
der wegen eines Tippfehlers im Import rot ist, hat nichts belegt.
Der Code, der ihn grün macht, in der ersten Fassung. Der Kern ist eine verankerte Ersetzung plus ein laut scheiternder Wächter im Einstiegspunkt:
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
Der grüne Lauf:
Das Aufräumen danach. In diesem Zyklus rein mechanisch:
ruff format formatierte die neue Testdatei vor dem Commit um
("1 file reformatted"), keine Logikänderung, die Suite blieb grün.
Wenn ein echtes Refactoring nötig ist (Benennung, Extraktion,
Deduplizierung), gilt derselbe Vertrag: die Tests werden nicht
angefasst und sind vorher wie nachher grün.
Den Zyklus in diesem Projekt fahren¶
cd backend && poetry run pytest tests/test_<deinedatei>.py -q # die enge Schleife
make test # das volle Gate vor dem Commit
Das Frontend-Äquivalent der engen Schleife ist
cd frontend && bunx vitest run src/pfad/zur/datei.test.tsx. Der
Pre-Commit-Hook führt die Backend-Smoke-Suite ohnehin bei jedem
Commit aus, und make test muss nach jeder Änderung grün sein.
Backend-pytest¶
make test-backend
cd backend && poetry run pytest -k "test_session" -v
cd backend && poetry run pytest --pdb # bei erstem Fehler in Debugger
Tests leben in backend/tests/. Fixtures in conftest.py
liefern pro Test eine frische In-Memory-SQLite-DB, den
TestClient und einen gemockten Plugin-Manager. Test-
Isolation ist hart - ADAPTIVE_LEARNER_TEST=1 wird vor jedem
app.*-Import gesetzt.
Plugin-Tests¶
Jedes Plugin hat sein eigenes tests/-Verzeichnis:
make test-plugins # alle 13
make test-plugin-session # nur eines
cd plugins/adaptive-learner-plugin-session && poetry run pytest
Plugin-Tests laden die FastAPI-App nicht - sie üben die
Plugin-Module isoliert. Mock den pluggy.PluginManager, wenn
du Hook-Firing testest.
Frontend-Vitest¶
make test-frontend # führt Vitest aus frontend/ aus
cd frontend && bunx vitest # Watch-Modus
cd frontend && bunx vitest run src/storage/ # ein Verzeichnis
Vitest aus frontend/ ausführen (die Konfiguration liegt in
frontend/vite.config.ts), oder über make test-frontend. Aus
dem Repo-Wurzelverzeichnis wird die Konfiguration nicht
gefunden, die node-Umgebung verwendet, und DOM-nutzende Tests
scheitern mit ReferenceError: document is not defined.
Tests liegen neben dem Quelltext: Component.test.tsx neben
Component.tsx. happy-dom ist die Umgebung; React 19 + RTL.
Die i18n-Paritäts-Prüfung (11 Sprachen), die
Theme-Token-Paritäts-Prüfung und die Design-Token-Prüfung
("keine hartkodierten Farben") laufen als Vitest-Tests in
derselben Suite.
Mock-Patterns¶
KI-Anbieter: global.fetch mocken und auf URL, Headers,
Body prüfen:
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: am Anfang jeder Dexie-Test-Datei:
import "fake-indexeddb/auto";
beforeEach(async () => {
await _resetDbForTests();
const {IDBFactory} = await import("fake-indexeddb");
(globalThis as unknown as {indexedDB: IDBFactory}).indexedDB = new IDBFactory();
});
Jeder Test bekommt eine frische In-Memory-IndexedDB - kein Leak.
api/client.ts-Mocks (Legacy-Seiten):
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}}};
});
Die Seite importiert getStorage(), das an ApiStorage
delegiert, das wiederum an api.* delegiert. Der Mock klinkt
sich auf der api.*-Ebene ein und feuert weiter durch den
Storage-Stack.
Playwright-E2E¶
cd e2e && npx playwright test
cd e2e && npx playwright test --ui # interaktiv
cd e2e && npx playwright test smoke/mobile-viewports.spec.ts
Smoke-Specs decken die kritischen User-Pfade ab:
- Landing-Sprachwahl + Onboarding-Formular
- Lerntyp-Test 12 Fragen + Radar
- Session starten + beenden + bewerten
- Einstellungen Sprache + API-Key
- Curriculum anlegen
- Mobile Viewports (iPhone SE, iPhone 14, Pixel 7, iPad)
Specs nutzen ausschließlich data-testid-Selektoren - keine
brüchigen CSS-Selektoren. Smoke-Specs sind NICHT im
make test-Pfad; sie brauchen eine laufende App
(make dev-bg zuerst).
Neben e2e/smoke/ enthält der e2e/-Baum drei weitere
Spec-Familien:
e2e/dexie/- das Dexie-Modus-Release-Gate. Baut das Frontend mitVITE_STORAGE_MODE=dexie(die GitHub-Pages-Form, ohne Backend) und läuft jede über die Navigation erreichbare Route ab; jeder Fehler-Toast oder Seitenabsturz lässt es scheitern. Ausführen mitmake test-dexie-smoke.e2e/visual/- Visuelle Baseline-Regressions-Specs.e2e/manual-automation/- Playwright-Automatisierung des manuellen Testplans.
Coverage¶
Coverage ist ein Bericht, kein Merge-Gate, und läuft daher
nicht bei PRs. Der coverage.yml-Workflow läuft nächtlich
(und auf Abruf); Artefakte herunterladen:
Targets per .claude/rules/quality-checks.md:
- Services + Business-Logik: 95% min
- API-Endpunkte: 90% min
- Frontend-Komponenten mit Logik: 85% min
- Hooks + Utilities: 95% min
Gesamt: 85-95% projektweit.
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,
eine Plugin-Lockfile/pyproject-Paarungs-Prüfung und ein
Bundled-Content-Statistik-Validator. Im CI-Pre-Commit-Job
werden die Hooks prettier-frontend und eslint
übersprungen (der Frontend-Tests-Job führt ESLint stattdessen
mit installierten Abhängigkeiten aus).
CI¶
CI teilt sich in zwei Stufen: Korrektheits-Gates laufen bei jedem PR (sie müssen zum Mergen grün sein), und die teuren oder nur-warnenden Suiten laufen zur Nachtschicht und beim Release.
.github/workflows/ci.yml läuft bei Push auf develop /
main und bei jedem PR (Python 3.12):
- Backend-Tests (pytest)
- Plugin-Tests (
make test-plugins, alle 13 über die Backend-venv) - Frontend:
tsc --noEmit, ESLint (--max-warnings 0), Circular-Dependency-Prüfung, Stylelint, Vitest,vite build,npm audit - Pre-Commit-Hooks über alle Dateien
- Backend ruff + mypy + pip-audit
- Docs-Drift-Verifizierer (
verify_docs.py+ mkdocs-nav-Sync)
Test Impact Analysis (#615): bei einem PR laufen nur die
betroffenen Tests - vitest run --changed origin/<base> und
pytest --testmon. Push auf develop / main, die
nächtlichen Läufe und der Release-Lauf führen immer die VOLLE
Suite aus. Der Rückfall auf die volle Suite ist automatisch
(nicht auflösbare Basis-Referenz oder ein testmon-Cache-Miss).
Zwei weitere PR-Gates leben in eigenen Workflows:
complexity-check.yml- das Komplexitäts-Ratschen-Gate (make check-complexity-gate, radon für Python + ESLint-Komplexität für TS). Es ist ein Baseline-Ratschen: es scheitert nur an NEUEN oder verschlechterten Verstößen gegenüber.complexity-baseline, blockiert also neue Komplexität, ohne ein Aufräumen der bestehenden Schuld zu erzwingen. Der volle nur-warnende Komplexitätsbericht läuft nächtlich.cohesion-check.yml- die Dateigrößen-Prüfung (Gate gegen.filesize-whitelist) plus zwei Klassen-Namens-Gates: tote CSS-Klassennamen (check-dead-classnames.pygegen.dead-classnames-baseline) und das Ungestylte-className-Gate (--unstyled, Ratsche gegen.unstyled-classnames-baseline) - einclassName, dessen Tokens alle tot sind, blockiert den PR. Die begleitende Ordnergrößen-Prüfung läuft lokal übermake check-folder-size.visual-baseline-gate.yml- ein PR, der visuell-kritische Pfade ändert (Lesson-Komponenten, Exercise-Renderer, Theme-/CSS-Dateien), muss die betroffenen Baseline-Screenshots im selben PR mitbringen; Escape-Labelvisual-baselines-unaffectedfür nachweislich inerte Änderungen.testid-reference-gate.yml- entfernt oder benennt ein PR eindata-testidum, das ein E2E-Spec statisch referenziert (auf einer stark nutzer-sichtbaren Fläche), ohne das Spec anzufassen, scheitert das Gate (make check-testid-refs); Escape-Labeltestid-refs-unaffected.docker-build-smoke.yml- Build-only-Smoke der Produktions-Compose-Images (der Launcher-/install.sh-Pfad), pfadgefiltert bei PRs, zusätzlich aufrelease/**, wöchentlich und per Abruf; lokalmake docker-build-smoke.
Nachtschicht / Release (nicht bei PRs):
dexie-smoke.yml- Dexie-Modus-E2E-Gate (täglich + aufrelease/**+ Abruf; lokalmake test-dexie-smoke)coverage.yml- Coverage-Bericht (täglich + Abruf)security-scan.yml- pip-audit / npm audit / bandit (wöchentlich + aufrelease/**+ Abruf; nur-warnend)content-stats.yml- Content-Statistik-Drift gegen ein frisches Content-Checkout (täglich + Abruf)mutation-frontend.yml- Stryker-Mutation-Testing (Nächtlich hinter der Repo-VariableENABLE_NIGHTLY_MUTATION+ Abruf; mutiert pro Lauf eine Datei-Scheibe, damit der Lauf ins Job-Zeitlimit passt); Backend-Mutation-Testing nutzt mutmutwebkit-gate.yml- das echte WebKit-Engine-Layout-Gate (iOS-/Safari-Bugklassen, die die Chromium-Gates strukturell nicht sehen), täglich hinter der Repo-VariableENABLE_NIGHTLY_WEBKIT, immer aufrelease/**und per Abrufvisual-regression.yml- die visuelle Baseline-Matrix (täglich + Abruf;update_baselines=truerendert die Baselines in CI neu und lädt sie als Artefakt hoch)visual-baseline-sync.yml- Service-Workflow: rendert die Baselines in CI und pusht sie als Commit auf den PR-Branch (Labelrefresh-visual-baselinesoder Abruf mit PR-Nummer) - die Bild-Review vor dem Merge bleibt Pflicht
.github/workflows/release-gate.yml läuft bei Tag-Pushes:
verifiziert, dass alle versionstragenden Dateien im Gleichschritt
sind (kein Drift), Plugin-Lockfiles passen, und regenerierte
Artefakte aktuell sind.
Manueller Testplan¶
Was Automatisierung nicht abdeckt (Layout, Lesbarkeit, Touch-Bedienung, Theme-Kontraste), prüft eine manuelle Checkliste vor jedem größeren Release: MANUAL-TESTPLAN.md.