26. Tests
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