pytest für LLM-Code

Track KI · M0 Baustein 06 · ca. 55 Min. (plus optional 15 Min. lokal)

Was du aus Teil a brauchst

Aus Teil 1: async/await für LLM-Aufrufe brauchst du nur die Idee eines Fake-Modells (eine Funktion mit fester Antwort statt eines echten Aufrufs) und die Bausteine Semaphore, timeout und gather. Den Code aus Teil 1 musst du hier nicht ausführen: Alle Beispiele in diesem Teil stehen für sich.

Worum es geht

Ein Modell antwortet nie zweimal dasselbe, und jeder echte Aufruf kostet Geld und Zeit. Darum brauchst du Tests, die ohne echtes Modell laufen: Fixtures mit Fake-Modell, Parametrisierung und Mocking. In dieser Lektion lernst du die vier pytest-Werkzeuge dafür kennen und prüfst, ob deine Tests wirklich etwas prüfen oder nur grün aussehen.

Echtes pytest läuft im Browser nicht. Darum gibt es einen Mini-Testläufer (lauf), der Fixtures und parametrize nach demselben Prinzip auflöst wie pytest. Echtes uv run pytest übst du lokal im Lernlabor. Testpyramide und Testen von KI-Code stehen in Konzepte 16.

Zeitplan: etwa 20 Minuten Lesen, 30 Minuten für die drei Übungen, dazu optional 15 Minuten lokal. Beitrag zu M0: Das Ziel “Tests grün” aus dem Lernplan. Du lernst, Code mit Modellaufrufen so zu testen, dass die Tests schnell, kostenlos und reproduzierbar sind.

Von JS/TS her gedacht

Idee JS/TS Python
Test mit Wiederholung der Eingaben test.each([...]) (Jest) @pytest.mark.parametrize
Vorbereitung pro Test beforeEach @pytest.fixture (der Test bestellt sie per Parametername)
Abhängigkeit im Test ersetzen jest.spyOn(...), jest.mock(...) monkeypatch.setattr(...)
Fehler erwarten expect(() => ...).toThrow() with pytest.raises(ValueError):

Ein Unterschied, der Anfänger trifft: In Jest steht die Vorbereitung in einem Block (beforeEach), den du nicht benennst. In pytest hat jede Fixture einen Namen, und ein Test fordert sie an, indem er einen Parameter mit genau diesem Namen deklariert.

Konzept

Schritt 1: pytest für Code mit Modellaufrufen

Ein Test beweist, dass der Code heute tut, was er soll. Bei KI-Code gibt es drei besondere Probleme: Das Modell ist langsam, kostet Geld und antwortet nicht reproduzierbar. Die Lösung ist immer dieselbe: Im Test ersetzt du das Modell durch ein Fake mit fester Antwort. Dafür braucht es vier pytest-Werkzeuge:

Werkzeug Zweck
@pytest.fixture baut Testdaten oder ein Fake-Modell auf. Der Test bestellt sie, indem er einen Parameter mit dem Namen der Fixture hat
@pytest.mark.parametrize dieselbe Testlogik mit mehreren Eingaben, ohne Code zu kopieren
pytest.raises der Test erwartet eine bestimmte Ausnahme (ungültige Eingabe)
monkeypatch tauscht für die Dauer eines Tests eine Funktion oder ein Attribut aus (z. B. das echte Modell) und stellt es danach wieder her

Echtes pytest läuft im Browser nicht. Darum steht hier ein Mini-Testläufer, der dieselben Ideen zeigt: lauf(tests) ruft jede Testfunktion auf. Für jeden Parameter des Tests schaut er, ob er aus parametrize kommt oder der Name einer Fixture ist, und baut sie dann frisch für jeden Test (innerhalb eines Tests nur einmal: Brauchen Test und eine zweite Fixture dieselbe, bekommen beide dasselbe Objekt). So arbeitet pytest auch (Dependency Injection nach Parametername). Du musst den Code nicht Zeile für Zeile lesen: Die Kommentare sagen, was jeder Teil tut, der Rest ist Mechanik.

Jetzt ein kleiner Testlauf. Der Code unter Test: eine Rechnung, die die Währung groß schreibt und einen Betrag unter oder gleich null ablehnt, und frage_modell, die eine Frage ans Modell schickt (app.llm). Das echte Modell wirft im Test absichtlich einen Fehler, weil es kein Netz gibt:

Ausgabe:

PASSED test_waehrung_wird_normalisiert[eur-EUR]
PASSED test_waehrung_wird_normalisiert[try-TRY]
PASSED test_waehrung_wird_normalisiert[Usd-USD]
PASSED test_fixture_liefert_rechnung
PASSED test_negativer_betrag_wird_abgelehnt
PASSED test_modell_ersetzt
PASSED test_echtes_modell_wirft
FAILED test_absichtlich_falsch  <- assert war falsch
7 von 8 bestanden
Modell nach dem Lauf wieder das echte: True

So liest du das: Aus einem parametrisierten Test werden drei Testfälle, die Fälle stehen in eckigen Klammern im Namen. rechnung ist keine normale Funktion mehr, sondern wird dem Test nach Namen geliefert. monkeypatch.setattr(app, "llm", ...) ersetzt das echte Modell nur im Test: Der letzte Testfall test_echtes_modell_wirft sieht wieder das echte (und es wirft), weil lauf nach jedem Test zurücksetzt. Und test_absichtlich_falsch zeigt, wie ein roter Test aussieht: Währung wird großgeschrieben, der Test erwartet klein.

Wie das in echtem pytest aussieht, ist fast identisch. Du schreibst import pytest, @pytest.fixture, @pytest.mark.parametrize("waehrung,erwartet", [...]) und with pytest.raises(ValueError):, und monkeypatch ist schon eingebaut. Zwei Unterschiede: pytest zeigt bei einem roten assert die beteiligten Werte an (der Mini-Läufer nicht), und pytest.approx(0.3) heißt bei dir approx(0.3). Läuft eine Fixture mit yield, ist alles nach dem yield das Aufräumen (der Mini-Läufer kennt das nicht).

Gegenprobe: Prüft der Test wirklich etwas? Ein grüner Test sagt nichts, wenn er auch bei kaputtem Code grün bliebe. Mach deshalb eine Gegenprobe von Hand: Lösche in Rechnung oben das .upper() und lass die Tests laufen. Mindestens ein Test muss jetzt rot werden (hier der parametrisierte Test und test_fixture_liefert_rechnung). Bleibt alles grün, prüfen deine Tests nichts. Genau das automatisiert Übung 3: Der Check baut absichtlich Fehler in die Funktion ein und schaut, ob deine Tests sie bemerken.

Async-Tests. pytest führt async def-Tests nicht von sich aus aus: Es braucht ein Plugin (üblich ist pytest-asyncio, im Lernlabor nicht installiert, bitte prüfen, ob du es brauchst) oder du rufst in einem normalen Test asyncio.run(...) auf. Das letzte machst du in der lokalen Zusatzübung. Die Fake-Modelle bleiben async, damit der Test das echte Verhalten (Warten, Gleichzeitigkeit) abbildet.

Falle

  1. Tests, die nie fehlschlagen. Das ist der Merksatz der Quelle. Ein besonders fieser Fall in Python:
def test_betrag():
    assert (Rechnung(1.0, "eur").waehrung == "eur", "Währung sollte großgeschrieben sein")

Die Klammer macht aus der Bedingung ein Tupel aus zwei Werten, und ein nichtleeres Tupel ist immer wahr. Der Test ist immer grün (Python warnt nur mit SyntaxWarning: assertion is always true). Die Meldung gehört hinter ein Komma ohne Klammer: assert bedingung, "Meldung". In Übung 1 suchst du genau solche Tests.

  1. Tests, die sich gegenseitig beeinflussen. Geteilter Zustand vererbt sich von Test zu Test: eine modulweite Liste, ein Klassenattribut statt eines Attributs in __init__, ein veränderlicher Default-Wert. Dann ist der Test nur in der richtigen Reihenfolge grün. Das übst du in Übung 2.
  2. Echte Modellaufrufe im Test. Langsam, teuer, nicht reproduzierbar. Test mit Fake, und höchstens einen kleinen, markierten Test mit echtem Modell, der lokal läuft.

Übungen

Übung 1: Welche Tests können nie rot werden? (leicht bis mittel, ca. 6 Min.)

Ein grüner Test ist nur etwas wert, wenn er auch rot werden kann. Hier sind sechs Tests für app.kosten (dieselbe Funktion wie in Übung 3, Preise pro 1 Million Tokens, Zahlen erfunden). Bei der richtigen Funktion sind alle sechs grün. Gesucht sind die Tests, die grün bleiben, egal welche Zahl kosten zurückgibt (die kaputte Funktion wirft dabei nie eine Ausnahme). Trage die Buchstaben der Tests als sortierte Liste ein, zum Beispiel ["a", "e"].

def test_a_beispiel():
    assert app.kosten(1000, 500, 2.0, 10.0) == approx(0.007)

def test_b_ohne_tokens():
    assert (app.kosten(0, 0, 3.0, 15.0) == 0, "ohne Tokens keine Kosten")

def test_c_negativ():
    try:
        app.kosten(-1, 0, 1.0, 1.0)
    except ValueError:
        pass

def test_d_ein_token():
    app.kosten(1, 0, 3.0, 15.0) == approx(0.000003)

def test_e_nicht_negativ():
    assert app.kosten(5, 5, 1.0, 1.0) >= 0

def test_f_zehn_tokens():
    assert app.kosten(10, 0, 3.0, 15.0) == approx(0.00003) or True

Spiele bei jedem Test durch: Die Funktion gibt plötzlich 123.0 zurück. Bleibt der Test grün? Was genau schreibt Python bei assert (a, "Text"), und was passiert bei einem Vergleich ohne assert davor?

antwort = ["b", "c", "d", "f"]
antwort

b: Die Klammer macht ein nichtleeres Tupel, das ist immer wahr. c: Ohne raises und ohne assert passiert nichts, egal ob die Ausnahme kommt oder nicht. d: Ein Vergleich ohne assert wird berechnet und weggeworfen. f: ... or True ist immer wahr. Die Tests a und e dagegen sind rot, sobald kosten eine falsche oder negative Zahl liefert.

Übung 2: Tests, die sich gegenseitig anstecken (mittel, ca. 10 Min.)

Die vier Tests unten prüfen, ob zusammenfassen den Text in den Prompt packt und das Modell genau so oft aufruft wie erwartet. Jede Fixture baut für jeden Test ein neues FakeLLM-Objekt. Trotzdem laufen die Tests zusammen nicht grün. Führe den Code einmal aus und lies, welcher Test rot ist und warum. Repariere den Code so, dass alle vier in jeder Reihenfolge grün sind. Du darfst die Tests nicht abschwächen: Sie müssen weiter rot werden, wenn zusammenfassen kaputt ist, und der Test mit zwei Modellen bleibt drin.

Zur Erinnerung: Ein Attribut, das direkt im Klassenkörper steht (prompts = []), gehört der Klasse und wird von allen Objekten geteilt, genau wie ein veränderlicher Default-Wert nur einmal entsteht. Ein Attribut, das in __init__ mit self.prompts = ... gesetzt wird, gehört dem einzelnen Objekt. lauf und fixture sind der Mini-Testläufer von oben, app.zusammenfassen(llm, text) ist der Code unter Test.

Die Fixture liefert wirklich jedes Mal ein neues Objekt. Wem gehört dann die Liste, die dieses Objekt benutzt? Gleich viele rote Tests wie Ursachen sind es nicht: Prüfe, ob eine Reparatur der Fixture allein den Test mit zwei Modellen heilen kann.

class FakeLLM:
    def __init__(self):
        self.prompts = []        # gehört dem einzelnen Objekt
    def __call__(self, prompt):
        self.prompts.append(prompt)
        return "Kurzfassung"

@fixture
def fake_llm():
    return FakeLLM()

@fixture
def zweites_llm():
    return FakeLLM()

def test_ein_aufruf(fake_llm):
    antwort = app.zusammenfassen(fake_llm, "Rechnung 17")
    assert antwort == "Kurzfassung"
    assert len(fake_llm.prompts) == 1

def test_prompt_enthaelt_text(fake_llm):
    app.zusammenfassen(fake_llm, "Zählerstand 4711")
    assert "Zählerstand 4711" in fake_llm.prompts[0]

def test_zwei_texte_zwei_aufrufe(fake_llm):
    app.zusammenfassen(fake_llm, "a")
    app.zusammenfassen(fake_llm, "b")
    assert len(fake_llm.prompts) == 2

def test_modelle_sind_getrennt(fake_llm, zweites_llm):
    app.zusammenfassen(fake_llm, "a")
    assert zweites_llm.prompts == []

tests = [test_ein_aufruf, test_prompt_enthaelt_text, test_zwei_texte_zwei_aufrufe, test_modelle_sind_getrennt]
bericht(lauf(tests))
tests

Die Liste gehört jetzt dem Objekt, nicht der Klasse. Die Fixture nur zu ändern (zum Beispiel FakeLLM.prompts = [] am Anfang) heilt die Reihenfolge, aber nicht den Test mit zwei Modellen: Beide Objekte teilen sich weiter dieselbe Klassenliste.

Übung 3: Tests schreiben, die Fehler fangen (schwer, ca. 12 Min.)

Das ist die Quellenübung, mit einer Prüfung dahinter, ob deine Tests wirklich etwas leisten (der Merksatz der Quelle). app.kosten(tokens_ein, tokens_aus, preis_ein, preis_aus) berechnet die Kosten eines Modellaufrufs. Die Preise gelten pro 1 Million Tokens (die Zahlen in dieser Aufgabe sind erfunden, keine echten Preise). Negative Tokenzahlen sind ungültig und lösen ValueError aus, null Tokens sind gültig. Schreibe mindestens vier Testfunktionen, davon eine mit parametrize und mindestens eine mit raises. Gib sie als Liste tests zurück.

Der Check macht zwei Dinge: Deine Tests müssen bei der richtigen Funktion grün sein. Und er tauscht app.kosten nacheinander gegen kaputte Varianten (jede mit einem anderen typischen Fehler) aus. Jede muss von mindestens einem deiner Tests entdeckt werden. So wird aus “Tests geschrieben” die Aussage “Tests, die etwas prüfen”.

Denke wie jemand, der kosten kaputt machen will: Welche typischen Fehler passieren bei einer Rechnung mit zwei Preisen und zwei Mengen? Und an welchen Stellen der Eingaben (Ränder des erlaubten Bereichs, einzelne Parameter) würde ein solcher Fehler nicht auffallen?

def test_beispielrechnung():
    assert app.kosten(1000, 500, 2.0, 10.0) == approx(0.007)

def test_nur_eingabe_und_nur_ausgabe():
    assert app.kosten(1_000_000, 0, 3.0, 15.0) == approx(3.0)
    assert app.kosten(0, 1_000_000, 3.0, 15.0) == approx(15.0)

def test_kleine_werte_werden_nicht_gerundet():
    assert app.kosten(100, 0, 3.0, 15.0) == approx(0.0003)

@parametrize("tokens_ein,tokens_aus", [(-1, 0), (0, -1), (-5, -5)])
def test_negative_tokens_werden_abgelehnt(tokens_ein, tokens_aus):
    with raises(ValueError):
        app.kosten(tokens_ein, tokens_aus, 1.0, 1.0)

def test_null_tokens_sind_gueltig():
    assert app.kosten(0, 0, 1.0, 1.0) == 0

tests = [test_beispielrechnung, test_nur_eingabe_und_nur_ausgabe, test_kleine_werte_werden_nicht_gerundet,
         test_negative_tokens_werden_abgelehnt, test_null_tokens_sind_gueltig]
tests

Lies die Liste als Strategie: ein Normalfall mit verschiedenen Preisen und Mengen (fängt Vertauschen und falsche Einheit), jede Seite einzeln (fängt “ignoriert Ausgabe”), ein sehr kleiner Wert (fängt Runden und ganzzahliges Abschneiden), der Fehlerfall pro Parameter und an der Kante (-1 direkt neben der gültigen 0, fängt “prüft nur eine Seite” und “Grenze um eins verschoben”), die genau verlangte Ausnahmeklasse (fängt “falscher Fehlertyp”) und der gültige Randfall null auf beiden Seiten (fängt “lehnt 0 ab”).

Zusatzübung (lokal): echtes pytest (optional, 15 Min.)

Die Quellenübung: fünf Tests für ein Pydantic-Modell, mindestens einer mit parametrize, einer mit pytest.raises. In lernlabor/uebung/ki/ki_03_pytest_lokal.py stehen ein kleines Pydantic-Modell Rechnung, die Funktionen frage_modell und frage_parallel und fünf Tests, die jeweils mit pytest.skip("TODO ...") beginnen. Du ersetzt die Platzhalter durch echte Tests (Fixture, parametrize, pytest.raises, monkeypatch, ein Test mit asyncio.run). Starte so (pytest und pydantic stehen schon in der pyproject.toml des Lernlabors, es wird nichts installiert):

cd lernlabor && uv run pytest -v uebung/ki/ki_03_pytest_lokal.py

Vorher steht dort “5 skipped”. Danach soll kein skipped mehr stehen. Mach die Gegenprobe wie in Übung 3 von Hand: Ändere im Code oben absichtlich etwas (z. B. gt=0 zu ge=0 oder .upper() löschen) und prüfe, ob mindestens ein Test rot wird. Danach übertrage dieselben fünf Arten Test auf dein eigenes Modell aus Baustein 04 in lernlabor/test/test_model.py.

Welche Eingaben würden dein Modell kaputt machen, wenn es die Prüfung nicht gäbe? Und wie ersetzt du in einem Test das Modell, damit kein Netz gebraucht wird?

@pytest.fixture
def beispiel_rechnung():
    return Rechnung(betrag=100.0, waehrung="eur")

def test_fixture_wird_genutzt(beispiel_rechnung):
    assert beispiel_rechnung.betrag == 100.0
    assert beispiel_rechnung.waehrung == "EUR"

@pytest.mark.parametrize("waehrung,erwartet", [("eur", "EUR"), ("try", "TRY"), (" usd ", "USD")])
def test_waehrung_wird_normalisiert(waehrung, erwartet):
    assert Rechnung(betrag=1.0, waehrung=waehrung).waehrung == erwartet

@pytest.mark.parametrize("betrag", [0, -5])
def test_ungueltiger_betrag_wird_abgelehnt(betrag):
    with pytest.raises(ValidationError):
        Rechnung(betrag=betrag, waehrung="eur")

def test_frage_modell_ohne_netz(monkeypatch):
    monkeypatch.setattr(sys.modules[__name__], "echtes_modell", lambda prompt: "  Hallo  ")
    assert frage_modell("Wie geht's?") == "Hallo"

def test_frage_parallel_haelt_limit():
    modell = FakeModell()
    prompts = [f"p{i}" for i in range(7)]
    antworten = asyncio.run(frage_parallel(modell, prompts, 3))
    assert antworten == ["ok: " + p for p in prompts]
    assert modell.max_aktiv == 3

Mit diesen Tests zeigt pytest 8 Fälle als bestanden (parametrisierte Tests zählen pro Fall), ausgeführt mit pytest 9.1.1, der Version im Lernlabor. max_aktiv == 3 beweist, dass die Semaphore wirkt.

Merksatz

Im Test ersetzt du das Modell durch ein Fake, jeder Test bekommt seinen eigenen Zustand, und ein Test, der nie fehlschlagen kann, gibt nur falsche Sicherheit.

Prüfstein

Du hast eine Funktion, die 200 Dokumente mit einem LLM zusammenfasst (Limit, Timeout, Fehler pro Dokument). Wie testest du sie ohne einen einzigen echten Modellaufruf? Welche drei Testfälle schreibst du als Erstes, und woran erkennst du, dass deine Tests wirklich etwas prüfen?

Ein Fake-Modell als Fixture (async, mit fester Antwort und einem Zähler für gleichzeitige Aufrufe, das auf Wunsch einen Fehler wirft oder zu lange wartet). Erste drei Testfälle: (1) Alle Dokumente liefern ok, in der Reihenfolge der Eingabe. (2) Der Zähler zeigt nie mehr als das Limit gleichzeitig. (3) Ein hängender und ein fehlerhafter Aufruf unter normalen: Die anderen Ergebnisse bleiben erhalten. Dazu parametrize für Randfälle (leere Liste, ein Dokument, genau so viele wie das Limit). Ob die Tests etwas prüfen, zeigt die Gegenprobe: Entferne absichtlich die Semaphore oder den Timeout, und mindestens ein Test muss rot werden.

Zurück: Teil 1: async/await für LLM-Aufrufe erklärt die Funktion, die du hier testest.


Quelle: quellen/kursbuch-lerninhalte.md, Modul M0, Baustein “06 pytest” (Zeilen 190 bis 237 der Datei): fixture, parametrize, Merksatz zu Tests, die nie fehlschlagen, die Übung (5 Tests mit parametrize und pytest.raises).

Über die Quelle hinaus (allgemeines Fachwissen): die Arbeitsweise von Fixtures als Dependency Injection nach Parametername, monkeypatch, der Mini-Testläufer (eigenes Hilfsmittel zum Üben, kein Teil von pytest), die Tupel-Falle bei assert, geteilter Zustand über Klassenattribute. Zahlen in den Ausgaben stammen aus ausgeführtem Code (Python 3.13.9, pytest 9.1.1 im Lernlabor). Bitte prüfen: ob du für async def-Tests pytest-asyncio oder anyio brauchst.