Teil 8 · Eine lokale KI-App testen: 351 Tests, Null Infrastruktur
Teil einer Serie über den Aufbau von Gemma CogniVault. Zuvor: Lernen gamifizieren — Badges, Heatmap und Idle-Gap-Sessions. Alle Abkürzungen werden vollständig im Anhang am Ende der Seite erklärt.
CogniVault hat 351 Tests verteilt auf 22 Dateien (zum Zeitpunkt des Schreibens — die Suite wächst mit der App). Keiner davon benötigt Ollama. Keiner benötigt Postgres. Keiner braucht ein echtes PDF, ein Mikrofon oder eine Internetverbindung. Die gesamte Suite läuft in etwa drei Sekunden auf meinem Laptop.
Das liegt nicht daran, dass es nicht viel zu testen gäbe — die Oberfläche ist groß. Es liegt daran, dass die Test-Suite um ein einziges Prinzip herum aufgebaut ist: An den Rändern mocken, überall sonst echt. In diesem Post geht es darum, was “der Rand” in einer lokalen KI-App bedeutet und wie man die Grenze so zieht, dass die Suite nützlich bleibt anstatt nur dekorativ zu sein.
Die 22 Testdateien
| Datei | Was sie abdeckt |
|---|---|
test_api.py | Die HTTP-Endpoints (Upload, Ingest, RAG, Historie, KB-Browsing) |
test_tools.py | Taschenrechner, Uhr, KB-Such-Tool |
test_thinking.py | Zwei-Phasen-Stream, Thinking-Tokens, Session-Isolation |
test_chat_attachments.py | Multi-File-Attach, PDF/DOCX-Extraktion, Größenlimits |
test_chat_memory.py | Budget für Session-Historie, Trimming, Restart-Rebuild |
test_doc_scope_filter.py | ContextVar-Isolation pro Request, Suchfilterung |
test_doc_tools.py | list_documents, analyze_document, compare_documents |
test_edit_regenerate.py | Historie zurückspulen, trim_history_to_turns-Validierung |
test_structure_chunking.py | Markdown-Header-Splits, CSV-Zeilen-Batches, Dokumenttypen |
test_ocr_fallback.py | OCR-Trigger-Schwellenwert, Graceful Degradation |
test_new_formats.py | PPTX-, XLSX-, HTML-Extraktoren, Extension-Routing |
test_docx_url.py | DOCX-Ingestion und URL-Import (mit dem SSRF-Schutz) |
test_reingest.py | SHA-256-Änderungserkennung, Idempotenz |
test_vector_db.py | BM25, FAISS, RRF-Fusion, Hybrid-Suche |
test_audio.py | Whisper-Transkriptions-Endpoint |
test_progress.py | Sessions, tägliche Aggregation, Achievement-Kriterien |
test_prompts.py | Der Prompt-Template-Loader und benutzerdefinierte Overrides |
test_vault_stats.py | Die Privacy Vault Audit-Zahlen |
test_quiz.py / test_workshop.py / test_flashcards.py / test_mindmaps.py | Parsing pro Modus, Endpoints, Achievements |
Alles, was isoliert getestet werden kann, wird isoliert getestet. Alles, was durch die FastAPI-Schicht getestet werden muss, wird dort getestet, aber die einzigen gemockten Dinge sind die Aufrufe, die die Prozessgrenze überschreiten.
Was gemockt wird, was nicht
Die mit Abstand wichtigste Frage in so einem Projekt: Wo setzt man den Stub an?
[ React frontend ] ←─ nicht im Scope für Backend-Tests
│
▼
[ FastAPI handlers ] ←─ direkt mit TestClient getestet
│
▼
[ services/ ] ←─ direkt getestet (vector_db, rag_agent, generators)
│
├─► [ FAISS + BM25 ] ←─ echt, in-memory, schnell
├─► [ SQLite ] ←─ echt, gegen eine tmp_path-Datei
├─► [ DBOS ] ←─ gepatched (kein Start, kein Postgres)
├─► [ Ollama ] ←─ gepatched am Import-Ort jedes Services
└─► [ Whisper ] ←─ als Stub (kein 145-MB-Modell-Laden)
Als Faustregel gilt: Alles, was eine Prozess- oder Netzwerkgrenze überschreitet, wird gemockt. Alles In-Process läuft echt.
FAISS und BM25 sind echt, weil es Bibliotheken sind, die wir in den Testprozess einbinden. SQLite ist echt, weil es eine Datei ist. DBOS ist gepatched, weil beim Starten eine Postgres-Verbindung erwartet wird, und das ist Netzwerk. Ollama ist gepatched, weil es HTTP ist. Whisper ist als Stub ausgeführt, weil das Laden eines 145 MB großen Modells in einem Unit-Test ziemlich albern ist.
Dieses Prinzip hält die Test-Suite schnell (kein I/O, den das OS nicht in Millisekunden verarbeiten kann) und aussagekräftig (die echten Code-Pfade durch Retrieval, Chunking, Parsing und Scope-Filterung werden ausgeführt).
Ollama mocken
Die meisten CogniVault-Tests brauchen irgendeinen Modell-Output, aber es ist ihnen egal, welches Modell ihn produziert hat. Jeder Service importiert das ollama-Modul direkt, daher patchen die Tests diese Referenz direkt am Import-Ort des Services:
# Real pattern from test_quiz.py
from unittest.mock import patch
from backend.services import quiz_generator
def test_quiz_parses_questions():
fake = {"message": {"content": json.dumps({"questions": [VALID_MCQ] * 5})}}
with patch.object(quiz_generator, "ollama") as mock_ollama:
mock_ollama.chat.return_value = fake
result = quiz_generator.generate_quiz(
difficulty="beginner", num_questions=5, question_types=["mcq"],
)
assert len(result.questions) == 5
Eine Streaming-Variante füttert Chunk-Sequenzen anstelle einer einzelnen Antwort; dies wird für die RAG- und Thinking-Tests verwendet. Die wichtigste Eigenschaft: Ein patch.object auf das Modul, das der Service tatsächlich benutzt. Keine tiefen Mock-Hierarchien, keine fragilen String-Pfade in Third-Party-Interna. Leicht in einem Code-Review zu lesen, leicht zu debuggen, wenn ein Test fehlschlägt.
DBOS mocken
DBOS erwartet, dass sich launch() mit Postgres verbindet. Die gemeinsam genutzte client-Fixture in der conftest.py patcht einfach die dbos-Instanz, bevor die App ausgeführt wird:
# Real pattern from conftest.py
@pytest.fixture()
def client():
"""A FastAPI TestClient with DBOS launch mocked out — no Postgres needed."""
with patch("backend.services.ingest.dbos") as mock_dbos:
mock_dbos.launch = MagicMock()
from backend.main import app
with TestClient(app) as c:
yield c
Die dekorierten Workflow-Schritte werden weiterhin als gewöhnliche Python-Funktionen ausgeführt — wir verlieren die Durability-Semantik, aber die Tests prüfen ja nicht Durability, sondern die Geschäftslogik innerhalb der Schritte (Hash-Erkennung, Extraktion, Chunking). Die Durability-Schicht hat ihre eigenen Tests weiter oben, in der eigenen Suite von DBOS.
Es gibt noch eine zweite Isolationsschicht, die jeden Test automatisch durchläuft: Eine Autouse-Fixture richtet den Docs-Ordner, den FAISS-Index und die Metadaten-Datei über Umgebungsvariablen auf einen tmp_path pro Test ein, sodass kein Test jemals echte Daten auf der Festplatte berühren kann.
Echtes SQLite, mit einem Override
Progress-Tracking, Achievements, Quiz-Speicherung, Deck-CRUD — alles SQLite. Der Progress-Tracker bietet eine einzige Test-Nahtstelle: Einen Pfad-Override auf Modulebene.
# Real pattern from test_quiz.py
@pytest.fixture(autouse=True)
def _isolate_progress_db(tmp_path, monkeypatch):
monkeypatch.setattr(progress_tracker, "_db_path_override",
str(tmp_path / "progress_test.db"))
Jeder Test bekommt eine frische Datenbankdatei; das Schema wird bei der ersten Nutzung automatisch erstellt. Kein Drama mit Connection-Pooling, kein durchgesickerter Status zwischen Tests, keine in-memory :memory:-Gymnastik. Einfach eine Temp-Datei pro Test.
Das ist die Art von Test, die Fehler aufdeckt, die ein Mock auf SQL-Ebene niemals sehen würde — ein fehlender Index, eine vermurkste Migration, ein Constraint, der nicht auslöst. SQLite ist auf jedem Rechner, den ich je besessen habe, so schnell, dass “die echte Datenbank nutzen” nicht mal ein Kompromiss ist.
Das TestClient-Pattern
Für HTTP-Tests führt FastAPIs TestClient die App in-process aus. Der Upload, die Validierung, das Chunking, das Vector-Store-Update, die Response-Serialisierung — jede Schicht läuft echt. Nur die Aufrufe, die den Prozess verlassen würden (der Ollama-Embedding-Aufruf in der Ingestion, der Modell-Aufruf in der Generierung), sind gepatched. Das ist genau die richtige Grenze: Der Test verifiziert die Integration dieser Schichten, hängt aber nicht von einem externen Service ab.
Die Streaming-Endpoint-Tests nutzen einen leicht anderen Stil — sie iterieren über den Response-Body und parsen jede NDJSON-Zeile (ein JSON-Envelope pro Zeile, wie im Streaming-Post beschrieben) — aber das Prinzip ist identisch.
Lücken in der Abdeckung, die ich akzeptiere
Drei Dinge, die die Test-Suite nicht abdeckt:
- Das Frontend. Keine React-Tests in dieser Suite — das ist ein separates Anliegen. Die meisten Fehler zeigen sich ohnehin in API-Tests, da das Frontend ein Thin-Client über einer typisierten API ist.
- Die tatsächliche Ollama-Prompt-Qualität. Ob
gemma4:e4bwirklich nützliche Quizfragen generiert, ist nichts, was Tests beantworten können. Das ist Evaluierung, kein Testing. Es gehört in eine separate Testumgebung, in der ein echtes Modell läuft. - Race Conditions über DBOS-Workflow-Restarts hinweg. Der Resume-Pfad wird auf Logikebene geprüft, aber der volle Zustandsraum von “Was passiert, wenn Postgres in genau diesem Moment weg ist” ist zu groß, um ihn komplett durchzuspielen.
Das sind bewusste Lücken. Die Test-Suite ist dazu da, Regressionen in meinem Code zu fangen; sie ist kein Ersatz für Evaluierung, Integrationstests oder gar echtes Chaos-Engineering.
Wofür die Suite eigentlich da ist
Zwei Dinge, in dieser Reihenfolge:
- Vertrauen beim Refactoring. Wenn ich die Agent-Loop rausreiße und eine neue einsetze, laufen die Tests dann immer noch grün durch? Wenn ja, haben sich die API-Verträge, die mir wichtig sind, nicht verschoben.
- Absicherung für PR-Reviews. Jeder PR lässt die Suite in der CI laufen. Ein grüner Durchlauf ist Voraussetzung für den Merge. Die Suite ist laut genug, dass eine echte Regression auch wirklich Lärm macht.
Beachte, wofür sie nicht da ist: um zu beweisen, dass das Modell funktioniert. Das kann sie nicht. Tests können Verhalten festnageln, aber keine Qualität. Das ist ein anderer Muskel, und er gehört in eine andere Testumgebung.
Was sich zum Ausborgen lohnt
Wenn du eine lokale KI-App baust und deine Tests Ollama am Laufen haben müssen:
- Patche das
ollama-Modul am Import-Ort jedes Services mitpatch.object(service_module, "ollama")— eine Nahtstelle pro Service, keine Shims nötig. - Gib deiner DB-Schicht einen Pfad-Override und lass sie gegen eine
tmp_path-SQLite-Datei laufen. - Nutze eine Autouse-Fixture, um jedes On-Disk-Artefakt (Docs-Ordner, Indexdateien) auf
tmp_pathumzuleiten, damit kein Test jemals versehentlich echte Daten berührt. - Ziehe für jeden externen Service (Modell, Audio, Workflow-Engine) die Naht an der Prozessgrenze. Teste alles darüber mit echtem Code.
Das Ergebnis ist eine Suite, in der jeder Test in jeder Umgebung läuft, in Millisekunden fertig ist und die tatsächliche Integration jeder von dir geschriebenen Codezeile testet. 351 Tests in etwa drei Sekunden sind keine Optimierung, sondern ein Nebeneffekt davon, dass man nur an den Rändern mockt.
Anhang: Abkürzungen in diesem Post
| Abkürzung | Volle Form | Bedeutung |
|---|---|---|
| CI | Continuous Integration | Automatisches Ausführen der Test-Suite bei jedem Push/PR |
| PR | Pull Request | Eine vorgeschlagene Code-Änderung — wird nur gemerged, wenn die Suite grün ist |
| API | Application Programming Interface | Die HTTP-Oberfläche, die der TestClient in-process testet |
| HTTP | HyperText Transfer Protocol | Das Protokoll, das die (in-process) Endpoint-Tests sprechen |
| RAG | Retrieval-Augmented Generation | Die Retrieval-then-Answer-Pipeline, die getestet wird |
| KB | Knowledge Base | Die indizierte Dokumentensammlung |
| FAISS | Facebook AI Similarity Search | Echt in Tests — es ist eine In-Process-Bibliothek |
| BM25 | Best Match 25 | Der Keyword-Index — auch echt in Tests |
| RRF | Reciprocal Rank Fusion | Die Rank-Merging-Formel, die in test_vector_db.py abgedeckt wird |
| SQLite / SQL | (SQL = Structured Query Language) | Die echte, dateibasierte Datenbank, gegen die jeder Progress-Test läuft |
| DBOS | Database-Oriented Operating System | Die Durable-Workflow-Bibliothek — gepatched, sodass kein Postgres nötig ist |
| OCR | Optical Character Recognition | Der Fallback für eingescannte PDFs mit eigenen Trigger-Threshold-Tests |
| SSRF | Server-Side Request Forgery | Die URL-Import-Angriffsklasse, die in test_docx_url.py abgedeckt ist |
| NDJSON | Newline-Delimited JSON | Das Streaming-Format, das die Endpoint-Tests Zeile für Zeile parsen |
| SHA-256 | Secure Hash Algorithm, 256-bit | Der Content-Fingerprint hinter den Re-Ingest-Tests |
| CRUD | Create, Read, Update, Delete | Die grundlegenden Speicheroperationen für Decks, Quizzes und Maps |
| PDF / DOCX / PPTX / XLSX / HTML | Portable Document Format / Word / PowerPoint / Excel / HyperText Markup Language | Die Extraktor-Formate mit dedizierten Tests |
Das war die Serie. Acht Posts über die Teile von Gemma CogniVault, auf die ich am stolzesten bin — und ein paar, die ich heute anders bauen würde. Wenn irgendetwas davon nützlich für dich war, der Code ist Open Source auf github.com/ndimoforaretas/local-gemma-rag zu finden und der Demo Walkthrough ist auf YouTube.
Deine Daten. Deine Hardware. Deine KI. Dein Vault.

Ähnliches
- CogniVault Backend erklärt, Teil 1 · Das Backend kennenlernen: Drei Prozesse, vier Schichten
- Gemma CogniVault
- Teil 5 · Zuverlässiges JSON aus einem lokalen LLM bekommen
- Teil 3 · Zwei-Phasen-Streaming: Zeigen, wie das Modell denkt, bevor es handelt
- Teil 2 · Hybrid Retrieval in der Praxis: FAISS + BM25, verschmolzen mit RRF