# Tests — Anleitung Test-Suite für die Serienbrief-Anwendung, basierend auf **pytest** + **pytest-django**. --- ## Inhaltsverzeichnis 1. [Überblick](#überblick) 2. [Schnellstart](#schnellstart) 3. [Test-Aufbau](#test-aufbau) 4. [Tests ausführen](#tests-ausführen) 5. [Coverage](#coverage) 6. [Eigene Tests schreiben](#eigene-tests-schreiben) 7. [Fixtures](#fixtures) 8. [Marker & Selektion](#marker--selektion) 9. [Debugging fehlschlagender Tests](#debugging-fehlschlagender-tests) 10. [Häufige Fehler](#häufige-fehler) 11. [CI-Integration](#ci-integration) 12. [Konventionen](#konventionen) --- ## Überblick Die Suite ist in drei Schichten aufgebaut: | Schicht | Datei | Was wird getestet | LibreOffice | |---|---|---|---| | **Unit** | `test_preview_service.py` | Service-Logik (CSV-Parsing, Header-Validierung, Result-Aufbau) | gemockt | | **View** | `test_preview_view.py` | HTTP-Layer (Auth, Form-Validation, Responses) | gemockt | | **Integration** | `test_preview_integration.py` | End-to-End mit echtem `soffice` | echt | Ziel: **Unit + View** laufen in **2–3 Sekunden** und werden bei jedem Code-Change ausgeführt. Integration läuft separat (manuell oder im Pre-Merge-CI), weil LibreOffice ~3–5 s pro Test braucht. --- ## Schnellstart ```bash cd ~/projekte/serienbrief # 1. Sicherstellen, dass der web-Container läuft docker compose ps web # 2. Test-DB initial anlegen (nur beim allerersten Mal nötig) docker compose exec web pytest --create-db mailmerge/tests/ -v # 3. Ab jetzt: schnelle Suite docker compose exec web pytest mailmerge/tests/ -v ``` Erwartung: ~22 Tests, alle grün, Laufzeit < 5 s. --- ## Test-Aufbau ``` app/mailmerge/tests/ ├── __init__.py ├── conftest.py # Geteilte Fixtures ├── test_preview_service.py # Unit-Tests für services/preview.py ├── test_preview_view.py # View-Tests via Django Test Client └── test_preview_integration.py # End-to-End mit echtem LibreOffice ``` `conftest.py` liefert mehrere wiederverwendbare Fixtures (siehe [Fixtures](#fixtures)). Konfiguration in `app/pyproject.toml`: ```toml [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings.dev" python_files = ["test_*.py", "*_test.py", "tests.py"] addopts = "--reuse-db -ra -m 'not integration'" markers = [ "integration: Test benötigt echte externe Tools (z.B. LibreOffice)", ] ``` - `--reuse-db`: Die Test-DB wird zwischen Läufen wiederverwendet — spart 2–3 s pro Lauf - `-ra`: Zusammenfassung am Ende inkl. Skipped/xFailed - `-m 'not integration'`: Integrationstests standardmäßig **aus** --- ## Tests ausführen Alle Befehle werden auf dem **Server** im `web`-Container ausgeführt. Working Directory im Container ist `/app`, lokal `~/projekte/serienbrief/app/`. ### Standardlauf (schnell, mocked) ```bash docker compose exec web pytest mailmerge/tests/ ``` ### Verbose mit Test-Namen ```bash docker compose exec web pytest mailmerge/tests/ -v ``` ### Einzelne Datei ```bash docker compose exec web pytest mailmerge/tests/test_preview_service.py -v ``` ### Einzelne Test-Klasse oder -Funktion ```bash # Eine ganze Klasse docker compose exec web pytest mailmerge/tests/test_preview_view.py::TestPreviewHappyPath -v # Ein einzelner Test docker compose exec web pytest mailmerge/tests/test_preview_view.py::TestPreviewHappyPath::test_returns_pdf_body -v ``` ### Per Pattern-Match ```bash # Alle Tests, deren Name 'extra_column' enthält docker compose exec web pytest mailmerge/tests/ -k extra_column -v ``` ### Mit Stop bei erstem Fehler ```bash docker compose exec web pytest mailmerge/tests/ -x ``` ### Mit detaillierter Traceback-Ausgabe ```bash docker compose exec web pytest mailmerge/tests/ -vv --tb=long ``` ### Integration-Tests (echter LibreOffice) ```bash # Nur Integrationstests docker compose exec web pytest mailmerge/tests/ -m integration -v # Alles (Unit + View + Integration) docker compose exec web pytest mailmerge/tests/ -m '' -v ``` `-m ''` neutralisiert den Default-Filter `not integration`. ### Test-DB neu aufbauen Wenn Modelländerungen seit dem letzten Lauf passiert sind, oder die DB inkonsistent erscheint: ```bash docker compose exec web pytest --create-db mailmerge/tests/ -v ``` ### Lokal außerhalb des Containers (devcontainer / virtualenv) Falls du außerhalb von Docker testen willst (z.B. im VS Code Devcontainer): ```bash cd app pytest mailmerge/tests/ -v ``` Voraussetzung: `requirements.txt` + `requirements-dev.txt` installiert, Postgres + Redis erreichbar, oder eine SQLite-Test-Override in `settings/dev.py`. --- ## Coverage `coverage.py` ist nicht in `requirements-dev.txt` enthalten — bei Bedarf nachinstallieren: ```bash docker compose exec web pip install coverage ``` ### Coverage messen ```bash docker compose exec web coverage run -m pytest mailmerge/tests/ docker compose exec web coverage report -m ``` ### Mit HTML-Report ```bash docker compose exec web coverage html -d /app/htmlcov # Report dann im Browser öffnen: # http://127.0.0.1:8000/static/htmlcov/index.html (wenn STATICFILES_DIRS angepasst) # oder einfach per docker compose exec ansehen ``` ### Coverage-Filter Nur die Mailmerge-App messen: ```bash docker compose exec web coverage run --source=mailmerge -m pytest mailmerge/tests/ docker compose exec web coverage report -m ``` ### Beispiel-Konfiguration (`.coveragerc` im `app/` Verzeichnis) ```ini [run] source = mailmerge omit = */migrations/* */tests/* */management/* [report] exclude_lines = pragma: no cover raise NotImplementedError if __name__ == .__main__.: if TYPE_CHECKING: show_missing = True ``` --- ## Eigene Tests schreiben ### Dateinamen `test_*.py` oder `*_test.py` — wird über `python_files` in `pyproject.toml` gesteuert. Empfehlung: konsistent `test_.py`. ### Test-Funktion vs. Test-Klasse Tests können als **Funktionen** oder in **Klassen** organisiert werden. Klassen helfen, verwandte Tests zu gruppieren: ```python def test_einzelner_fall(): assert 1 + 1 == 2 class TestNutzerRegistrierung: def test_email_pflicht(self, client): ... def test_passwort_min_länge(self, client): ... ``` Wichtig: pytest braucht **keine** `setUp`/`tearDown` und kein Erben von `TestCase`. Fixtures übernehmen das. ### Datenbankzugriff Tests, die die DB nutzen, brauchen das `db`-Fixture oder müssen `@pytest.mark.django_db` annotiert sein: ```python import pytest @pytest.mark.django_db def test_letter_template_creation(user): from mailmerge.models import LetterTemplate tpl = LetterTemplate.objects.create(name="Test", created_by=user) assert tpl.id is not None ``` Bei Verwendung des `user`-Fixtures aus `conftest.py` wird `db` automatisch mitgezogen. ### Mock-Pattern für externe Tools LibreOffice oder andere langsame externe Aufrufe **immer** in Unit-Tests mocken: ```python def test_etwas(monkeypatch, tmp_path): def fake_libreoffice(docx_path, out_dir): out_dir.mkdir(parents=True, exist_ok=True) pdf = out_dir / "fake.pdf" pdf.write_bytes(b"%PDF-1.4 fake") return pdf monkeypatch.setattr( "mailmerge.services.preview.docx_to_pdf", fake_libreoffice, ) # ... Test ausführen ... ``` **Wichtig:** Monkey-Patch immer den **importierten** Namen — also dort, wo er **benutzt** wird, nicht wo er definiert ist. Beispiel: Wenn `views.py` macht `from .services.preview import build_preview`, dann patcht man `mailmerge.views.build_preview` und **nicht** `mailmerge.services.preview.build_preview`. ### Beispiel: Neuer View-Test ```python import pytest from django.urls import reverse @pytest.mark.django_db class TestMeineNeueView: def test_login_required(self, client): response = client.get(reverse("meine-view")) assert response.status_code == 302 def test_user_kann_zugreifen(self, auth_client): response = auth_client.get(reverse("meine-view")) assert response.status_code == 200 assert b"Erwarteter Inhalt" in response.content ``` --- ## Fixtures Alle in `conftest.py` definierten Fixtures sind automatisch in allen Test-Dateien verfügbar. | Fixture | Typ | Was es liefert | |---|---|---| | `template_placeholders` | `list[str]` | Standard-Platzhalter `["anrede_brief", "nachname", "vorname", "ort"]` | | `docx_bytes` | `bytes` | DOCX-Datei im Speicher mit den Platzhaltern | | `docx_file_on_disk` | `Path` | DOCX als Datei in `tmp_path` | | `csv_valid_bytes` | `bytes` | Valide CSV mit 2 Datenzeilen | | `csv_uploaded_valid` | `SimpleUploadedFile` | Upload-fertige CSV für POST-Requests | | `user` | `User` | Frischer Django-User in der Test-DB | | `auth_client` | `Client` | Eingeloggter Django Test Client | | `letter_template` | `LetterTemplate` | DB-Objekt + hochgeladene DOCX-Datei | Zusätzliche CSV-Konstanten in `conftest.py`: ```python from mailmerge.tests.conftest import ( CSV_VALID, CSV_MISSING_COLUMN, # 'ort' fehlt CSV_EXTRA_COLUMN, # 'personalnr' überflüssig CSV_NO_DATA, # nur Header CSV_NO_HEADER, # leere Datei ) ``` ### Eigene Fixtures hinzufügen Wenn eine Fixture **nur** für eine Datei gebraucht wird → direkt in der Test-Datei definieren. Wird sie **mehrfach** gebraucht → in `conftest.py` heben. ```python # In der Test-Datei selbst @pytest.fixture def besondere_csv(): return "spalte1,spalte2\nwert1,wert2\n".encode("utf-8") ``` --- ## Marker & Selektion ### Built-in Marker - `@pytest.mark.django_db` — Test braucht DB-Zugriff - `@pytest.mark.skip(reason="...")` — Test temporär überspringen - `@pytest.mark.skipif(condition, reason="...")` — Bedingt überspringen - `@pytest.mark.xfail` — Test darf fehlschlagen (z.B. bekannter Bug) - `@pytest.mark.parametrize` — Test mit mehreren Datensätzen ### Custom Marker Aktuell registriert (in `pyproject.toml`): - `@pytest.mark.integration` — Test braucht echte externe Tools (LibreOffice) Neue Marker müssen in `pyproject.toml` unter `markers` registriert werden, sonst gibt's eine Warnung. ### Selektive Ausführung ```bash # Nur Tests mit Marker 'integration' pytest -m integration # Tests ohne Marker 'integration' (default) pytest -m 'not integration' # Mehrere Marker kombinieren pytest -m 'slow and not integration' ``` ### Parametrisierte Tests ```python import pytest @pytest.mark.parametrize("input,expected", [ ("hallo", 5), ("welt", 4), ("", 0), ]) def test_länge(input, expected): assert len(input) == expected ``` Erzeugt drei Tests: `test_länge[hallo-5]`, `test_länge[welt-4]`, `test_länge[-0]`. --- ## Debugging fehlschlagender Tests ### Mehr Output ```bash # Output von print() etc. anzeigen (sonst von pytest unterdrückt) docker compose exec web pytest mailmerge/tests/ -s # Vollständiger Traceback docker compose exec web pytest mailmerge/tests/ --tb=long # Lokale Variablen im Traceback docker compose exec web pytest mailmerge/tests/ --showlocals # Kombination docker compose exec web pytest mailmerge/tests/ -vvs --tb=long --showlocals ``` ### Bei erstem Fehler stoppen ```bash docker compose exec web pytest mailmerge/tests/ -x ``` ### Letzten fehlgeschlagenen Test neu laufen lassen ```bash docker compose exec web pytest mailmerge/tests/ --lf # last failed docker compose exec web pytest mailmerge/tests/ --ff # failed first, dann der Rest ``` ### Mit pdb `breakpoint()` an die fragliche Stelle im Code setzen, dann: ```bash docker compose exec web pytest mailmerge/tests/ -s --pdb ``` Bei Fehler springt pytest in den Debugger. ### Mit VS Code Debugger Die `.vscode/launch.json` hat bereits eine **Django: pytest** Konfiguration. Diese attached debugpy an den Container. Breakpoints im Test- oder App-Code setzen, F5 drücken. Alternativ einzelnen Test im VS Code via Test-Explorer starten — Pytest-Plugin erkennt die Suite automatisch. --- ## Häufige Fehler ### `django.db.utils.OperationalError: database "test_serienbrief" does not exist` Test-DB fehlt. Lösung: ```bash docker compose exec web pytest --create-db mailmerge/tests/ -v ``` ### `RuntimeError: Database access not allowed` Test greift auf die DB zu, ist aber nicht als DB-Test markiert. Lösung: ```python @pytest.mark.django_db def test_xyz(): ... ``` oder das `db`-Fixture injizieren. ### `fixture 'xyz' not found` - Fixture-Datei nicht in `conftest.py`? - Tippfehler im Fixture-Namen? - `__init__.py` in `mailmerge/tests/` fehlt? Check: ```bash docker compose exec web ls -la mailmerge/tests/__init__.py docker compose exec web pytest --fixtures mailmerge/tests/ | grep xyz ``` ### `Cannot assign "": "MailMergeJob.created_by" must be a "User" instance` Eines der Modelle hat ein Feld mit eigenem User-Model, das von dem in `conftest.py` benutzten `get_user_model()` abweicht. Sollte mit `AUTH_USER_MODEL` automatisch zusammenpassen, sonst Fixture anpassen. ### Integrationstests laufen nicht durch, weil LibreOffice fehlt ``` SKIPPED [1] LibreOffice (soffice) nicht im PATH ``` Das ist korrekt — die Skipif-Bedingung schützt vor falschen Failures. Im `web`-Container ist `soffice` immer da; auf anderen Hosts evtl. nicht. Erzwingen: ```bash docker compose exec web which soffice # sollte /usr/bin/soffice o.ä. zeigen ``` ### `--reuse-db` führt zu kaputten Tests nach Modelländerung Wenn ein Modell geändert wurde, ist die wiederverwendete Test-DB veraltet: ```bash docker compose exec web pytest --create-db mailmerge/tests/ ``` --- ## CI-Integration Aktuell nicht eingerichtet — Vorschlag für GitHub Actions oder GitLab CI: ### Beispiel-Stage `test` für GitLab CI ```yaml test: stage: test image: docker:27 services: - docker:27-dind variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: "" before_script: - apk add --no-cache docker-compose - cp .env.example .env - openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt script: - docker compose build web - docker compose up -d db redis - docker compose run --rm web pytest --create-db mailmerge/tests/ -v after_script: - docker compose down -v ``` ### Beispiel für GitHub Actions ```yaml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup environment run: | cp .env.example .env openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt - name: Build & test run: | docker compose build web docker compose up -d db redis docker compose run --rm web pytest --create-db mailmerge/tests/ -v ``` ### Empfehlung: zweistufig 1. **Pre-Merge / Push**: nur schnelle Suite (`pytest mailmerge/tests/`) 2. **Nightly oder Pre-Release**: alles inkl. Integration (`pytest -m '' mailmerge/tests/`) So bleibt das Feedback im Entwickleralltag schnell, aber LibreOffice-Regressionen werden regelmäßig geprüft. --- ## Konventionen ### Naming - Test-Dateien: `test_.py` (z.B. `test_preview_service.py` für `services/preview.py`) - Test-Klassen: `TestVerhalten` oder `TestEntität` (PascalCase, ohne Unterstriche) - Test-Funktionen: `test_` (snake_case, beschreibend) Gute Namen: ```python def test_preview_pdf_response_has_inline_disposition(): ... def test_csv_with_bom_strips_bom_from_first_column(): ... ``` Schlechte Namen: ```python def test_preview1(): ... def test_it_works(): ... ``` ### Struktur eines Tests — Arrange / Act / Assert ```python def test_preview_returns_pdf_bytes(docx_file_on_disk, patched_libreoffice): # Arrange: Eingaben aufbauen csv_buf = io.BytesIO(CSV_VALID.encode("utf-8")) # Act: das System Under Test aufrufen result = build_preview(docx_file_on_disk, csv_buf) # Assert: erwartetes Verhalten prüfen assert isinstance(result, PreviewResult) assert result.pdf_bytes.startswith(b"%PDF") or len(result.pdf_bytes) > 0 ``` ### Eine Assertion pro Test (wenn sinnvoll) Statt: ```python def test_preview(): result = ... assert result.pdf_bytes assert result.placeholders assert result.extra_columns == [] ``` Lieber drei Tests mit klaren Namen — bei einem Fehler weißt du sofort, **welcher** Aspekt kaputt ist. Bei eng verwandten Assertions in einem Test ist das aber pragmatisch oft OK. ### Was **nicht** getestet werden muss - Django-Framework-Verhalten (Authentication, ORM, etc.) — wird upstream getestet - Triviale Property-Getter ohne Logik - `__str__`-Methoden, es sei denn sie haben spezielle Format-Logik Was getestet werden **muss**: - Eigene Geschäftslogik (Validierung, Berechnung, Format-Konvertierung) - Fehlerpfade (was passiert bei Input X?) - HTTP-Layer-Contracts (Auth, Content-Types, Status-Codes) --- ## Aktuell offene Punkte (Roadmap) - [ ] Tests für `mailmerge.tasks.run_mailmerge` (Celery-Worker-Pfad) - [ ] Tests für `template_upload`-View - [ ] Tests für `job_create` und `job_detail` (HTMX-Polling) - [ ] Tests für `job_download` (X-Accel-Redirect-Header in Production) - [ ] CI-Pipeline aufsetzen - [ ] Coverage-Target setzen (z.B. ≥80% für `mailmerge/`) - [ ] Mutation-Testing erwägen (`mutmut`) für kritische Service-Funktionen