17 KiB
Tests — Anleitung
Test-Suite für die Serienbrief-Anwendung, basierend auf pytest + pytest-django.
Inhaltsverzeichnis
- Überblick
- Schnellstart
- Test-Aufbau
- Tests ausführen
- Coverage
- Eigene Tests schreiben
- Fixtures
- Marker & Selektion
- Debugging fehlschlagender Tests
- Häufige Fehler
- CI-Integration
- 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
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).
Konfiguration in app/pyproject.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)
docker compose exec web pytest mailmerge/tests/
Verbose mit Test-Namen
docker compose exec web pytest mailmerge/tests/ -v
Einzelne Datei
docker compose exec web pytest mailmerge/tests/test_preview_service.py -v
Einzelne Test-Klasse oder -Funktion
# 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
# Alle Tests, deren Name 'extra_column' enthält
docker compose exec web pytest mailmerge/tests/ -k extra_column -v
Mit Stop bei erstem Fehler
docker compose exec web pytest mailmerge/tests/ -x
Mit detaillierter Traceback-Ausgabe
docker compose exec web pytest mailmerge/tests/ -vv --tb=long
Integration-Tests (echter LibreOffice)
# 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:
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):
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:
docker compose exec web pip install coverage
Coverage messen
docker compose exec web coverage run -m pytest mailmerge/tests/
docker compose exec web coverage report -m
Mit HTML-Report
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:
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)
[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:
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:
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:
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
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:
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.
# 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
# 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
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
# 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
docker compose exec web pytest mailmerge/tests/ -x
Letzten fehlgeschlagenen Test neu laufen lassen
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:
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:
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:
@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__.pyinmailmerge/tests/fehlt?
Check:
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:
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:
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
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
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
- Pre-Merge / Push: nur schnelle Suite (
pytest mailmerge/tests/) - 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.pyfürservices/preview.py) - Test-Klassen:
TestVerhaltenoderTestEntität(PascalCase, ohne Unterstriche) - Test-Funktionen:
test_<was_getestet_wird>(snake_case, beschreibend)
Gute Namen:
def test_preview_pdf_response_has_inline_disposition(): ...
def test_csv_with_bom_strips_bom_from_first_column(): ...
Schlechte Namen:
def test_preview1(): ...
def test_it_works(): ...
Struktur eines Tests — Arrange / Act / Assert
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:
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_createundjob_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