Files
serienbrief_django/TEST.md
T
2026-05-22 08:40:04 +02:00

17 KiB
Raw Blame History

Tests — Anleitung

Test-Suite für die Serienbrief-Anwendung, basierend auf pytest + pytest-django.


Inhaltsverzeichnis

  1. Überblick
  2. Schnellstart
  3. Test-Aufbau
  4. Tests ausführen
  5. Coverage
  6. Eigene Tests schreiben
  7. Fixtures
  8. Marker & Selektion
  9. Debugging fehlschlagender Tests
  10. Häufige Fehler
  11. CI-Integration
  12. 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

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 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)

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__.py in mailmerge/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

  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:

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_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