Du musst eine Funktion testen, bei der die korrekte Ausgabe im Voraus unmöglich zu kennen ist. Ein Routenoptimierer. Ein Sentiment-Classifier. Eine Physiksimulation. Du hast über Metamorphic Testing gelesen: Finde Relationen zwischen Eingaben und Ausgaben, die gelten müssen, und teste diese Relationen statt exakter Werte.
Das Problem ist, die Relations zu finden. Du starrst auf die Funktionssignatur und siehst nur Weiß.
Also fragst du ein LLM. Es spuckt in Sekunden zehn metamorphische Relations aus. Die meisten sehen plausibel aus. Einige sind offensichtlich falsch. Eines oder zwei sind genuin clever. Das LLM ist ein brauchbarer Brainstorming-Partner, aber es ist kein Ersatz für Domänenwissen. Füge diese Relations ungeprüft in deine Test-Suite ein, und du wirst Tests ausliefern, die auf kaputtem Code bestehen oder auf korrektem Code fehlschlagen.
Was das LLM dir tatsächlich gibt
Wenn du ein LLM bittest, metamorphische Relations für eine Funktion vorzuschlagen, führt es fortgeschrittenes Pattern Matching über Trainingsdaten durch. Es hat tausende Implementierungen von Sortieralgorithmen, geometrischen Bibliotheken und Machine-Learning-Pipelines gesehen. Es erkennt, dass „eine Konstante zu addieren den Mittelwert um dieselbe Konstante verschieben sollte“ eine häufige Eigenschaft ist, und schlägt sie vor.
Das ist kein Verstehen. Es ist statistische Assoziation. Das LLM weiß nicht, ob deine Funktion leere Listen, NaN-Werte oder Integer Overflow behandelt. Es schlägt die Relation vor, weil es ähnliche schon gesehen hat.
So sieht ein typischer Prompt und eine typische Antwort aus. Du fütterst das LLM mit einer Funktion und bittest um Relations:
# Your function under test
def percentile(values: list[float], p: float) -> float:
"""Return the p-th percentile using linear interpolation."""
if not values:
raise ValueError("empty list")
if not 0.0 <= p <= 1.0:
raise ValueError("p must be in [0, 1]")
sorted_vals = sorted(values)
n = len(sorted_vals)
idx = p * (n - 1)
lower = int(idx)
upper = lower + 1
if upper >= n:
return sorted_vals[-1]
frac = idx - lower
return sorted_vals[lower] * (1 - frac) + sorted_vals[upper] * frac
Prompt: Propose 5 metamorphic relations for this percentile function.
Response:
1. Monotonicity: if p1 < p2, then percentile(values, p1) <= percentile(values, p2)
2. Scaling: percentile([v * k for v in values], p) == percentile(values, p) * k
3. Translation: percentile([v + c for v in values], p) == percentile(values, p) + c
4. Permutation invariance: percentile(values, p) == percentile(shuffled(values), p)
5. Boundary: percentile(values, 0.0) == min(values), percentile(values, 1.0) == max(values)
Drei davon sind korrekt und nützlich. Eine ist subtil falsch. Eine ist trivial wahr, aber so locker, dass sie fast keine Bugs erwischt.
Die Monotonie-Relation ist korrekt, obwohl du doppelte Werte behandeln musst. Die Skalierungs-Relation versagt für k <= 0, weil die Sortierreihenfolge sich umkehrt. Die Translations-Relation ist solide. Permutationsinvarianz testet hauptsächlich, ob du sortiert hast. Die Grenzwert-Relation ist nur korrekt, wenn deine Interpolation das 0. und 100. Perzentil als Minimum und Maximum behandelt.
Das LLM warnt dich vor nichts davon. Es präsentiert alle fünf mit gleicher Zuversicht.
Wie man LLM-generierte Relations filtert
Der nützliche Workflow ist nicht „Frag das LLM, kopiere die Ausgabe, geh zum Mittagessen.“ Er ist: „Frag das LLM, behandle die Ausgabe als Kandidatenliste, und verifiziere dann jeden Kandidaten mit Schlussfolgerung und Testen.“
Schritt eins ist, jede vorgeschlagene Relation nach Typ zu klassifizieren. Strukturelle Relations, wie Permutationsinvarianz oder Idempotenz, tendieren dazu, sicherer zu sein, weil sie weniger von Domänensemantik abhängen. Arithmetische Relations, wie Skalierung oder Translation, sind mächtig, wenn sie gelten, aber sie versagen oft bei Edge Cases, die das LLM nicht bedacht hat: negative Multiplikatoren, leere Collections, Fließkommarundung.
Schritt zwei ist die Gegenbeispiel-Suche. Für jede vorgeschlagene Relation versuche, eine Eingabe zu finden, bei der sie versagt. Das ist der schnellste Weg, LLM-Halluzinationen zu erkennen.
def test_percentile_scaling_counterexample():
"""The LLM proposed scaling. It fails for negative k."""
values = [1.0, 2.0, 3.0, 4.0]
original = percentile(values, 0.5) # 2.5
k = -1.0
scaled_values = [v * k for v in values]
scaled_result = percentile(scaled_values, 0.5) # -2.5
# The relation holds here by accident. For nearest-rank,
# multiplying by a negative flips sort order and breaks it.
assert abs(scaled_result - original * k) < 1e-9
Die Skalierungs-Relation hält sich hier zufällig für lineare Interpolation, aber du weißt das nur, weil du es getestet hast. Das LLM wusste es nicht. Es hat auf Basis von Pattern Matching geraten. Ein anderer Perzentil-Algorithmus hätte Skalierung auf offensichtliche Weise gebrochen.
Schritt drei ist Mutation Testing. Sobald du eine Relation als Test hast, führe ein Mutation-Testing-Tool dagegen aus. Wenn Mutanten überleben, ist deine Relation zu schwach. Wenn korrekter Code getötet wird, ist deine Relation falsch.
Wo LLMs glänzen und wo sie versagen
LLMs sind genuin nützlich für die Entdeckung von Relations in gut erforschten Domänen. Sie wissen, dass Image-Classifier invariant gegenüber horizontalen Spiegelungen sein sollten, dass Sortieralgorithmen idempotent sein sollten und dass Matrizenmultiplikation über Addition distributieren sollte. Das LLM erinnert sich an diese kanonischen Relations sofort.
Sie sind weniger nützlich in Domänen mit impliziten Einschränkungen, die nicht in den Trainingsdaten auftauchen. Wenn du eine Custom Pricing Engine mit Geschäftsregeln über regionale Rabatte und Aktionscode-Interaktionen testest, hat das LLM keine Ahnung. Es wird generische arithmetische Relations vorschlagen, die die Geschäftslogik ignorieren, oder schlimmer, Relations vorschlagen, die ihr widersprechen.
Die Fehlermodi sind vorhersehbar:
Übermäßig allgemeine Relations. Das LLM schlägt „Ausgabe sollte positiv sein“ für eine Funktion vor, die eine Wahrscheinlichkeit zurückgibt. Das ist ein schwacher Sanity Check, keine metamorphische Relation. Er erwischt Abstürze, aber keine Logik-Bugs.
Relations, die Stetigkeit annehmen. Das LLM schlägt vor, dass kleine Eingabeänderungen kleine Ausgabeänderungen erzeugen. Das versagt für Threshold-Funktionen und diskrete Classifier.
Relations, die Type-Einschränkungen ignorieren. Das LLM schlägt vor, eine Liste von Dataclasses nach einem Feld zu sortieren und dann zu prüfen, dass das Feld des ersten Elements das Minimum ist. Es vergisst, dass manche Felder optional sein könnten oder dass der Comparison-Operator für diesen Typ nicht definiert sein könnte.
Relations, die mathematisch falsch sind. Das LLM hat einmal vorgeschlagen, dass der Median einer konkatenierten Liste gleich dem Durchschnitt der Mediane der Teillisten ist. Das stimmt nicht. Das LLM präsentierte es mit derselben Zuversicht wie Permutationsinvarianz.
Ein pragmatischer Workflow
Bitte das LLM nicht, dein Gehirn zu ersetzen. Bitte es, den Teil zu beschleunigen, bei dem du auf eine leere Seite starrst.
Beginne damit, eine Funktion in einem Absatz zu beschreiben, einschließlich Types, Einschränkungen und bekannter Edge Cases. Je mehr Kontext du gibst, desto weniger halluziniert das LLM.
Bitte um Relations, nach Typ kategorisiert: Invarianz-Relations, Monotonie-Relations, additive Relations und strukturelle Relations. Diese Rahmung hilft dem LLM, sein Pattern Matching zu organisieren und liefert konsistentere Ausgaben.
Für jede vorgeschlagene Relation durchlaufe diese Checkliste:
- Gilt sie für leere Eingaben?
- Gilt sie für Eingaben mit einem einzelnen Element?
- Gilt sie für negative Werte, Null, NaN oder Unendlichkeit?
- Gilt sie, wenn die Eingabe bereits sortiert ist? Umgekehrte Reihenfolge?
- Kann ich einen Mutation Test schreiben, der Mutanten nur dann tötet, wenn diese Relation gilt?
Behalte die Relations, die alle fünf überleben. Verwerfe den Rest und dokumentiere, warum.
Hier ist eine Vorlage für den Prompt, den wir intern verwenden:
SYSTEM_PROMPT = """
You are a testing assistant. Given a Python function, propose metamorphic relations.
For each relation:
1. State the relation clearly
2. Identify the type: invariance, monotonicity, additive, or structural
3. List edge cases where it might fail
4. Rate confidence as HIGH, MEDIUM, or LOW
"""
def generate_relations(source_code: str) -> list[dict]:
response = openai.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Propose relations:\n\n{source_code}"}
],
temperature=0.3,
)
return parse_relation_candidates(response.choices[0].message.content)
Bei 0,3 ist das LLM weniger kreativ, aber konsistenter. Für metamorphische Relations schlägt Konsistenz Kreativität. Du willst die langweiligen, korrekten Relations, nicht die cleveren, falschen.
Der echte Engpass bist immer noch du
LLMs können die Entdeckung beschleunigen, aber sie können die Verifizierung nicht ersetzen. Eine metamorphische Relation, die du nicht persönlich validiert hast, ist kein Test. Sie ist ein Vermutung, verpackt in eine Assertion.
Die ehrliche Antwort auf „Kann ein LLM Test Oracles für mich entdecken?“ ist: teilweise. Es kann Kandidaten entdecken, dein Gedächtnis auffrischen und Edge Cases vorschlagen. Es kann dir nicht sagen, welche Relations für deine spezifische Implementierung, mit deinen spezifischen Einschränkungen, in deiner spezifischen Domäne korrekt sind.
Dieser Teil erfordert immer noch einen Menschen, der den Code versteht. Das LLM ist ein Brainstorming-Partner, kein Oracle für die Oracles.
Wenn du bei Null anfängst, wähle eine Funktion mit einem schwachen Oracle, frage ein LLM nach fünf Relations und verbringe dann zwanzig Minuten damit, jede zu brechen. Die Relations, die überleben, sind dein Seed-Set. Diejenigen, die brechen, lehren dich mehr über deine Funktion als das LLM je könnte.