Artikel

26. Tests

Allgemein

Tests prüfen nicht die Plattformen selbst. Sie prüfen die eigene Logik:

Generator
Quality-Gate
JSON-Validierung
Limits
Publisher-Dry-Run
OpenAI-Mock

Nicht sinnvoll im MVP:

echte LinkedIn-Posts im Test
echte Instagram-Uploads im Test
echte Facebook-Veröffentlichungen im Test
echte OpenAI-Aufrufe in Unit-Tests

Unit-Tests müssen offline laufen.

26.1 Unit-Tests für Generator

Der Generator wird gegen gemockte Abhängigkeiten getestet.

Zu prüfen:

ContentSourceItem wird verarbeitet
Prompt wird gebaut
OpenAI-Client wird aufgerufen
PostDraft wird erzeugt
Status ist draft
Prompt-Name und Modell werden übernommen
leerer KI-Text wird abgelehnt

Beispiel:

from datetime import datetime
from zoneinfo import ZoneInfo

from src.generator import ContentGeneratorService
from src.generator import IdFactory
from src.generator import PostDraft


class FakePromptBuilder:
    def build_for_content_item(self, platform: str, post_type: str, item: object) -> object:
        class PromptResult:
            prompt_name = "workshop_note"
            prompt_version = "1.0.0"
            input_text = "Test prompt"
            max_output_tokens = 500

        return PromptResult()


class FakeOpenAiClient:
    def create_text(self, request: object) -> object:
        class Result:
            text = "Gemeinfreie Texte sind frei verfügbar, aber nicht automatisch verlagsfertig."
            model = "gpt-4.1-mini"

        return Result()


class FakeIdFactory:
    def create_post_draft_id(self) -> str:
        return "draft_test_001"


class FakeTextValidator:
    def validate(self, platform: str, post_type: str, text: str) -> None:
        if text.strip() == "":
            raise RuntimeError("Text must not be empty.")


class FakeContentSourceItem:
    id = "topic_001"
    source_type = "manual_topic"
    title = "Test"
    body = "Test body"
    author = None
    book_title = None
    url = None
    image_path = None
    tags = ["verlagswerkstatt"]
    platform_hint = "linkedin"
    post_type_hint = "workshop"


def test_generator_creates_draft() -> None:
    service = ContentGeneratorService(
        prompt_builder=FakePromptBuilder(),
        openai_client=FakeOpenAiClient(),
        id_factory=FakeIdFactory(),
        text_validator=FakeTextValidator(),
    )

    draft = service.generate_draft(
        item=FakeContentSourceItem(),
        platform="linkedin",
        post_type="workshop",
        safety_identifier="test",
        model="gpt-4.1-mini",
    )

    assert draft.id == "draft_test_001"
    assert draft.status == "draft"
    assert draft.platform == "linkedin"
    assert draft.post_type == "workshop"
    assert draft.prompt_name == "workshop_note"
    assert draft.prompt_version == "1.0.0"
    assert draft.model == "gpt-4.1-mini"

26.2 Unit-Tests für Quality-Gate

Das Quality-Gate ist besonders testbar, weil es deterministische Regeln enthält.

Zu prüfen:

gültiger Beitrag besteht
leerer Text fällt durch
zu langer Text fällt durch
verbotene Floskel fällt durch
ungültige Domain fällt durch
doppelter Text fällt durch
Instagram ohne Bild fällt durch
Kommentar mit Link fällt durch

Beispiel:

from src.quality_gate import PostDraft
from src.quality_gate import QualityGate


def create_valid_draft() -> PostDraft:
    return PostDraft(
        id="draft_test_001",
        source_type="manual_topic",
        source_id="topic_001",
        platform="linkedin",
        post_type="workshop",
        status="draft",
        text="Gemeinfreie Texte sind frei verfügbar, aber nicht automatisch verlagsfertig. Korrektur und Metadaten bleiben echte Arbeit.",
        image_path=None,
        target_url="https://www.null-papier.de",
        prompt_name="workshop_note",
        prompt_version="1.0.0",
        model="gpt-4.1-mini",
        quality_result=None,
        created_at="2026-05-12T09:00:00+02:00",
        updated_at="2026-05-12T09:00:00+02:00",
    )


def test_quality_gate_passes_valid_draft() -> None:
    quality_gate = QualityGate()
    draft = create_valid_draft()

    result = quality_gate.check(
        draft=draft,
        previous_texts=[],
    )

    assert result.passed is True
    assert result.reason == "passed"


def test_quality_gate_rejects_empty_text() -> None:
    quality_gate = QualityGate()
    draft = create_valid_draft()

    invalid_draft = PostDraft(
        id=draft.id,
        source_type=draft.source_type,
        source_id=draft.source_id,
        platform=draft.platform,
        post_type=draft.post_type,
        status=draft.status,
        text="",
        image_path=draft.image_path,
        target_url=draft.target_url,
        prompt_name=draft.prompt_name,
        prompt_version=draft.prompt_version,
        model=draft.model,
        quality_result=draft.quality_result,
        created_at=draft.created_at,
        updated_at=draft.updated_at,
    )

    result = quality_gate.check(
        draft=invalid_draft,
        previous_texts=[],
    )

    assert result.passed is False
    assert "empty" in result.reason.lower()


def test_quality_gate_rejects_sales_pressure() -> None:
    quality_gate = QualityGate()
    draft = create_valid_draft()

    invalid_draft = PostDraft(
        id=draft.id,
        source_type=draft.source_type,
        source_id=draft.source_id,
        platform=draft.platform,
        post_type=draft.post_type,
        status=draft.status,
        text="Jetzt kaufen: ein Muss für alle Literaturfreunde.",
        image_path=draft.image_path,
        target_url=draft.target_url,
        prompt_name=draft.prompt_name,
        prompt_version=draft.prompt_version,
        model=draft.model,
        quality_result=draft.quality_result,
        created_at=draft.created_at,
        updated_at=draft.updated_at,
    )

    result = quality_gate.check(
        draft=invalid_draft,
        previous_texts=[],
    )

    assert result.passed is False

26.3 Tests für JSON-Validierung

JSON-Dateien sind fehleranfällig. Tests prüfen:

Datei enthält Liste
jedes Element ist Objekt
Pflichtfelder sind vorhanden
Feldtypen stimmen
leere Pflichtstrings werden abgelehnt

Beispiel für JsonStore:

import json
from pathlib import Path

import pytest

from src.json_store import JsonStore


def test_json_store_loads_list(tmp_path: Path) -> None:
    file_path = tmp_path / "items.json"

    file_path.write_text(
        json.dumps(
            [
                {
                    "id": "item_001"
                }
            ]
        ),
        encoding="utf-8",
    )

    store = JsonStore(file_path)
    items = store.load_list()

    assert len(items) == 1
    assert items[0]["id"] == "item_001"


def test_json_store_rejects_object_root(tmp_path: Path) -> None:
    file_path = tmp_path / "items.json"

    file_path.write_text(
        json.dumps(
            {
                "id": "item_001"
            }
        ),
        encoding="utf-8",
    )

    store = JsonStore(file_path)

    with pytest.raises(RuntimeError):
        store.load_list()


def test_json_store_missing_file_returns_empty_list(tmp_path: Path) -> None:
    file_path = tmp_path / "missing.json"

    store = JsonStore(file_path)
    items = store.load_list()

    assert items == []

Test für Buchquelle:

import json
from pathlib import Path

import pytest

from src.content_sources import JsonBookSource


def test_json_book_source_loads_books(tmp_path: Path) -> None:
    file_path = tmp_path / "books.json"

    file_path.write_text(
        json.dumps(
            [
                {
                    "id": "book_001",
                    "title": "Stolz und Vorurteil",
                    "author": "Jane Austen",
                    "language": "de",
                    "category": "klassiker",
                    "description": "Beschreibung",
                    "shop_url": "https://www.null-papier.de",
                    "cover_image_path": "storage/images/test.jpg",
                    "tags": [
                        "klassiker"
                    ]
                }
            ]
        ),
        encoding="utf-8",
    )

    source = JsonBookSource(file_path)
    books = source.load()

    assert len(books) == 1
    assert books[0].id == "book_001"
    assert books[0].title == "Stolz und Vorurteil"


def test_json_book_source_rejects_missing_title(tmp_path: Path) -> None:
    file_path = tmp_path / "books.json"

    file_path.write_text(
        json.dumps(
            [
                {
                    "id": "book_001",
                    "author": "Jane Austen",
                    "language": "de",
                    "category": "klassiker",
                    "description": "Beschreibung",
                    "shop_url": "https://www.null-papier.de",
                    "cover_image_path": "storage/images/test.jpg",
                    "tags": [
                        "klassiker"
                    ]
                }
            ]
        ),
        encoding="utf-8",
    )

    source = JsonBookSource(file_path)

    with pytest.raises(RuntimeError):
        source.load()

26.4 Tests für Limits

Limits betreffen:

Zeichenlänge
Tageslimits
Kommentarlimits
Posting-Zeitfenster
Fehlerlimits
Hashtag-Limits

Zeichenlimit:

import pytest

from src.quality_gate import CharacterLimitValidator
from src.quality_gate import QualityGateError


def test_character_limit_accepts_linkedin_text() -> None:
    validator = CharacterLimitValidator()

    validator.validate(
        platform="linkedin",
        post_type="workshop",
        text="a" * 900,
    )


def test_character_limit_rejects_linkedin_text_above_limit() -> None:
    validator = CharacterLimitValidator()

    with pytest.raises(QualityGateError):
        validator.validate(
            platform="linkedin",
            post_type="workshop",
            text="a" * 901,
        )

Hashtag-Limit:

from src.hashtags import HashtagGenerator


def test_instagram_hashtags_are_limited_to_five() -> None:
    generator = HashtagGenerator()

    hashtags = generator.generate(
        platform="instagram",
        tags=[
            "klassiker",
            "ebook",
            "verlag",
            "jane austen",
            "public domain",
            "buch",
            "literatur",
        ],
    )

    assert len(hashtags) == 5

Queue-Limit:

from src.scheduler import DailyLimitChecker


def test_daily_limit_rejects_second_published_post() -> None:
    checker = DailyLimitChecker(timezone="Europe/Berlin")

    posts = [
        {
            "platform": "linkedin",
            "status": "published",
            "published_at": "2026-05-12T09:30:00+02:00",
        }
    ]

    checker.assert_post_allowed(
        posts=posts,
        platform="linkedin",
        max_posts_per_day=2,
    )

Für zeitabhängige Tests sollte die Zeit später injiziert werden. Für das MVP kann man diese Tests auf reine Zählfunktionen beschränken.

26.5 Dry-Run für Publisher

Publisher-Tests dürfen nicht live posten.

Getestet wird:

PublishRequest wird akzeptiert
Dry-Run setzt Status dry_run
Screenshot-Pfad wird gesetzt
Queue wird aktualisiert
Live-Modus wird nicht versehentlich verwendet

Der echte Playwright-Adapter wird nicht als Unit-Test gegen LinkedIn ausgeführt. Stattdessen wird ein Fake-Adapter verwendet.

from pathlib import Path

from src.publisher import PlatformAdapter
from src.publisher import PublishRequest
from src.publisher import PublishResult
from src.publisher import PlatformAdapterRegistry


class FakeDryRunAdapter(PlatformAdapter):
    def publish(self, request: PublishRequest) -> PublishResult:
        assert request.dry_run is True

        return PublishResult(
            post_id=request.id,
            platform=request.platform,
            status="dry_run",
            screenshot_path="storage/screenshots/linkedin/test_dry_run.png",
            platform_post_url=None,
            error_message=None,
        )


def test_registry_returns_registered_adapter() -> None:
    registry = PlatformAdapterRegistry()
    registry.register("linkedin", FakeDryRunAdapter())

    adapter = registry.get("linkedin")

    assert isinstance(adapter, FakeDryRunAdapter)

Test für Result-Applier:

from src.publisher import PublishedResultApplier
from src.publisher import PublishResult


def test_published_result_applier_sets_dry_run_status() -> None:
    draft = {
        "id": "draft_test_001",
        "status": "approved",
    }

    result = PublishResult(
        post_id="draft_test_001",
        platform="linkedin",
        status="dry_run",
        screenshot_path="storage/screenshots/linkedin/test_dry_run.png",
        platform_post_url=None,
        error_message=None,
    )

    applier = PublishedResultApplier()
    updated = applier.apply(draft, result)

    assert updated["status"] == "dry_run"
    assert updated["screenshot_path"] == "storage/screenshots/linkedin/test_dry_run.png"
    assert "dry_run_at" in updated

26.6 Mock für OpenAI

OpenAI wird in Tests nicht echt aufgerufen.

Fake-Client:

class FakeOpenAiClient:
    def __init__(self, text: str) -> None:
        self.text = text
        self.requests: list[object] = []

    def create_text(self, request: object) -> object:
        self.requests.append(request)

        class Result:
            pass

        result = Result()
        result.text = self.text
        result.model = "gpt-4.1-mini"
        result.input_tokens = 100
        result.output_tokens = 50
        result.total_tokens = 150
        result.response_id = "resp_test"

        return result

Test:

def test_fake_openai_client_records_request() -> None:
    client = FakeOpenAiClient("Testantwort")

    result = client.create_text(
        request={
            "model": "gpt-4.1-mini"
        }
    )

    assert result.text == "Testantwort"
    assert result.model == "gpt-4.1-mini"
    assert len(client.requests) == 1

Mock für JSON-Ausgabe:

class FakeOpenAiJsonClient:
    def __init__(self, payload: dict[str, object]) -> None:
        self.payload = payload

    def create_json(self, request: object) -> dict[str, object]:
        return self.payload

Quality-Check-Test:

from src.openai_quality import QualityGateResultValidator


def test_openai_quality_result_validator_accepts_valid_payload() -> None:
    payload = {
        "passed": True,
        "reason": "ok",
        "checks": {
            "not_generic": True,
            "not_too_salesy": True,
            "has_clear_topic": True,
            "within_length": True,
            "safe_for_publishing": True,
        },
    }

    validator = QualityGateResultValidator()
    result = validator.validate(payload)

    assert result.passed is True
    assert result.reason == "ok"

26.7 Beispiel: pytest

Installation:

pip install pytest

Struktur:

tests/
├── test_generator.py
├── test_quality_gate.py
├── test_json_validation.py
├── test_limits.py
├── test_publisher_dry_run.py
└── test_openai_mock.py

pytest.ini:

[pytest]
testpaths = tests
python_files = test_*.py

Ausführen:

pytest

Ausführlicher:

pytest -vv

Einzelne Datei:

pytest tests/test_quality_gate.py -vv

Einzelner Test:

pytest tests/test_quality_gate.py::test_quality_gate_rejects_empty_text -vv

Beispiel: tests/test_quality_gate.py

from src.quality_gate import PostDraft
from src.quality_gate import QualityGate


def create_valid_draft() -> PostDraft:
    return PostDraft(
        id="draft_test_001",
        source_type="manual_topic",
        source_id="topic_001",
        platform="linkedin",
        post_type="workshop",
        status="draft",
        text="Gemeinfreie Texte sind frei verfügbar, aber nicht automatisch verlagsfertig. Korrektur und Metadaten bleiben echte Arbeit.",
        image_path=None,
        target_url="https://www.null-papier.de",
        prompt_name="workshop_note",
        prompt_version="1.0.0",
        model="gpt-4.1-mini",
        quality_result=None,
        created_at="2026-05-12T09:00:00+02:00",
        updated_at="2026-05-12T09:00:00+02:00",
    )


def test_quality_gate_passes_valid_draft() -> None:
    quality_gate = QualityGate()
    draft = create_valid_draft()

    result = quality_gate.check(
        draft=draft,
        previous_texts=[],
    )

    assert result.passed is True


def test_quality_gate_rejects_duplicate() -> None:
    quality_gate = QualityGate()
    draft = create_valid_draft()

    result = quality_gate.check(
        draft=draft,
        previous_texts=[
            draft.text
        ],
    )

    assert result.passed is False
    assert "duplicate" in result.reason.lower()

Beispiel: tests/test_comment_generator.py

from src.comment_generator import CommentGenerationRequest
from src.comment_generator import CommentGenerator


def test_comment_generator_suggests_relevant_comment() -> None:
    generator = CommentGenerator()

    request = CommentGenerationRequest(
        source_text="Kleine Anbieter haben oft gute Produkte, aber zu wenig Sichtbarkeit.",
        publisher_context="E-Book-Verlag, Klassiker, digitale Produktion.",
        previous_comments=[],
    )

    result = generator.generate(request)

    assert result.status == "suggested"
    assert result.comment_text is not None


def test_comment_generator_rejects_political_topic() -> None:
    generator = CommentGenerator()

    request = CommentGenerationRequest(
        source_text="Die Wahl und die Parteien bestimmen die Debatte.",
        publisher_context="E-Book-Verlag, Klassiker, digitale Produktion.",
        previous_comments=[],
    )

    result = generator.generate(request)

    assert result.status == "rejected"
    assert result.comment_text is None

Mindest-Testset für MVP

Generator erzeugt PostDraft
Quality-Gate akzeptiert gültigen Beitrag
Quality-Gate blockiert leeren Beitrag
Quality-Gate blockiert Verkaufsdruck
Quality-Gate blockiert Dublette
JSON-Store lädt gültige Liste
JSON-Store lehnt Objekt-Root ab
Hashtag-Limit greift
Publisher-Dry-Run setzt dry_run
OpenAI-Fake liefert kontrollierte Antwort
Kommentargenerator blockiert Politik
Kommentargenerator blockiert generisches Lob

Ergebnis dieses Kapitels

Die Tests prüfen jetzt:

Generatorlogik
Quality-Gate
JSON-Validierung
Limits
Dry-Run-Verhalten
OpenAI-Mocks
Kommentarlogik

Damit wird das System testbar, ohne Social-Media-Plattformen oder OpenAI in Unit-Tests live anzusprechen.

0 Kommentare