Files
2026-05-22 08:40:04 +02:00

639 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 **23 Sekunden** und werden bei jedem Code-Change ausgeführt. Integration läuft separat (manuell oder im Pre-Merge-CI), weil LibreOffice ~35 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 23 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_<modul>.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 "<User>": "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_<modul>.py` (z.B. `test_preview_service.py` für `services/preview.py`)
- Test-Klassen: `TestVerhalten` oder `TestEntität` (PascalCase, ohne Unterstriche)
- Test-Funktionen: `test_<was_getestet_wird>` (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