Du lieferst ein Machine-Learning-Modell aus, das Support-Tickets labelt. Deine Test-Suite ist grün. Jeder Test hat bestanden.
Keiner dieser Tests prüft tatsächlich, ob die Labels korrekt sind. Du weißt nicht, was die richtigen Labels sind. Niemand weiß es. Die „korrekte“ Ausgabe ist für reale Eingaben praktisch unkenntlich, also greifst du dazu, zu prüfen, dass die Funktion nicht abstürzt oder dass die Ausgabeform den Erwartungen entspricht. Das ist kein Testen. Das ist Hoffen.
Das ist das Oracle-Problem, und es taucht überall auf: Compiler, Simulationen, Optimierungsalgorithmen, Betrugserkennungssysteme und jedes System, bei dem die Ground Truth teuer, subjektiv oder unmöglich zu berechnen ist. Wenn du die erwartete Ausgabe nicht definieren kannst, fallen traditionelle Unit Tests auseinander.
Metamorphic Testing bietet einen Ausweg. Statt zu fragen „Ist diese Ausgabe korrekt?“, fragst du „Erzeugen verwandte Eingaben Ausgaben, die eine bekannte Beziehung erfüllen?“ Wenn nicht, ist dein Code kaputt. Wenn ja, hast du echte Vertrauensgewinnung erzielt, ohne jemals die richtige Antwort zu kennen.
Was ist Metamorphic Testing?
Metamorphic Testing wurde Ende der 1990er-Jahre von Tsong Yueh Chen und Kollegen als Antwort auf eine frustrierende Realität vorgeschlagen: Viele Programme sind genuin nützlich, aber mit traditionellen Oracles praktisch untestbar.
Die Kernidee ist einfach. Du identifizierst eine metamorphische Relation: eine Eigenschaft, die zwischen den Eingaben und Ausgaben mehrerer Ausführungen deines Programms gelten sollte. Du führst das Programm mit einer Quell-Eingabe aus, transformierst diese Eingabe gemäß der Relation, führst es erneut aus und prüfst, ob sich die Ausgaben zueinander so verhalten, wie die Eigenschaft es vorhersagt.
Keine erwartete Ausgabe nötig. Keine menschlichen Labeler. Kein Gold-Datensatz.
Betrachte eine Funktion, die den kürzesten Pfad in einem gewichteten Graphen findet:
from typing import List, Tuple, Optional
def shortest_path(
edges: List[Tuple[int, int, float]],
start: int,
end: int
) -> Optional[float]:
"""Dijkstra's algorithm. Returns path length or None if unreachable."""
import heapq
graph = {}
for u, v, w in edges:
graph.setdefault(u, []).append((v, w))
dist = {start: 0.0}
heap = [(0.0, start)]
while heap:
d, u = heapq.heappop(heap)
if u == end:
return d
if d > dist.get(u, float('inf')):
continue
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return None
Für einen komplexen Graphen ist die manuelle Berechnung des erwarteten kürzesten Pfads mühsam. Aber wir kennen mehrere metamorphische Relationen, die gelten müssen:
-
Monotonie bezüglich Kantengewichten. Wenn du das Gewicht einer einzelnen Kante erhöhst, sollte der kürzeste Pfad nicht kürzer werden. Er kann gleich bleiben (wenn diese Kante nicht auf dem optimalen Pfad lag) oder länger werden.
-
Homogenität unter Skalierung. Wenn du jedes Kantengewicht mit einer positiven Konstanten multiplizierst, sollte die Länge des kürzesten Pfads um denselben Faktor skaliert werden.
-
Pfadsymmetrie in ungerichteten Graphen. Wenn der Graph ungerichtet ist, sollte das Vertauschen von Start und Ende dieselbe Pfadlänge ergeben.
Das sind keine Heuristiken. Das sind mathematische Eigenschaften. Wenn eine davon fehlschlägt, ist die Implementierung falsch, Punkt.
Wie man einen metamorphen Test schreibt
So sieht die Monotonie-Relation in der Praxis aus:
import random
def test_shortest_path_monotonicity():
# Generate a random connected graph
nodes = list(range(10))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(1.0, 10.0)))
# Add some random cross edges
for _ in range(10):
u, v = random.sample(nodes, 2)
edges.append((u, v, random.uniform(1.0, 10.0)))
start, end = 0, 9
original = shortest_path(edges, start, end)
assert original is not None
# Increase the weight of one arbitrary edge
idx = random.randrange(len(edges))
u, v, w = edges[idx]
modified_edges = list(edges)
modified_edges[idx] = (u, v, w + 5.0)
modified = shortest_path(modified_edges, start, end)
assert modified is not None
assert modified >= original
Dieser Test berechnet nie den erwarteten kürzesten Pfad. Er muss das nicht. Er prüft, dass eine strukturelle Eigenschaft gilt, was genug ist, um eine überraschende Vielfalt von Bugs zu erwischen: Vorzeichenfehler, Off-by-One-Fehler in der Gewichtsakkumulation, falsche Priority-Queue-Ordnung und mehr.
Die Skalierungs-Relation ist noch einfacher zu testen:
def test_shortest_path_scaling():
nodes = list(range(8))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(2.0, 5.0)))
start, end = 0, 7
original = shortest_path(edges, start, end)
factor = 3.5
scaled_edges = [(u, v, w * factor) for u, v, w in edges]
scaled = shortest_path(scaled_edges, start, end)
assert abs(scaled - original * factor) < 1e-9
Beachte die Fließkommatoleranz. Metamorphe Tests sind nicht immun gegen numerische Präzisionsprobleme, also schreibe deine Assertions mit derselben Sorgfalt, die du in jedem anderen numerischen Test anwendest.
Wo dieser Ansatz wirklich hilft
Metamorphic Testing glänzt in Domänen, in denen traditionelle Oracles schwach oder nicht existent sind.
Machine Learning. Du kennst nicht den exakten Sentiment-Score für eine Filmkritik, aber du weißt, dass das Hinzufügen des Worts „schrecklich“ den positiven Sentiment-Score nicht erhöhen sollte. Du kennst nicht die exakte Bounding Box für einen Objektdetektor, aber du weißt, dass das horizontale Spiegeln des Bilds die Bounding-Box-Koordinaten spiegeln sollte.
Compiler. Zu prüfen, dass ein optimierter Binary für jedes mögliche Programm exakt dieselbe Ausgabe erzeugt wie ein unoptimierter, ist unmöglich. Aber du kannst prüfen, dass ein Programm zu kompilieren und dann erneut mit einer No-Op-Transformation (wie dem Umbenennen einer Variable) zu kompilieren, semantisch äquivalente Binaries erzeugt.
Wissenschaftliches Rechnen. Du kennst nicht die exakte Trajektorie eines Teilchens in einer komplexen Simulation, aber du weißt, dass das Umkehren der Zeit die Trajektorie umkehren sollte. Du kennst nicht die exakte Grundzustandsenergie eines Moleküls, aber du weißt, dass sie abnehmen (oder konstant bleiben) sollte, wenn du die Basisset-Größe erhöhst.
In jedem Fall ist die Erkenntnis dieselbe: Korrektheit dreht sich nicht immer darum, einen einzelnen erwarteten Wert zu treffen. Manchmal dreht es sich darum, Struktur über Transformationen hinweg zu bewahren.
Die Kompromisse und Grenzen
Metamorphic Testing ist nicht umsonst, und es ist kein Ersatz für jede andere Testart.
Relationen können unvollständig sein. Ein Programm kann jede von dir definierte metamorphische Relation bestehen und trotzdem falsch sein. Wenn dein Relations-Satz eine bestimmte Bug-Klasse nicht abdeckt, schlüpft dieser Bug durch. Das ist das Coverage-Problem, und es ist real.
Relationen können falsch sein. Wenn du fälschlicherweise behauptest, dass eine Eigenschaft gilt, wenn sie das nicht tut, wird dein Test zu einer False-Positive-Fabrik. Ich habe einmal ein Team gesehen, das behauptete, k-Means-Clustering sei invariant unter Feature Scaling. Ist es nicht. Die Zentroide skalieren mit den Daten. Der Test bestand monatelang, weil der Skalierungsfaktor im Testdatensatz zufällig 1,0 war. Als echte Daten ankamen, verlor das Modell leise an Qualität, während die Tests grün blieben.
Fehler zu debuggen ist schwieriger. Wenn ein traditioneller Unit Test fehlschlägt, weißt du genau, was die erwartete Ausgabe war. Wenn ein metamorpher Test fehlschlägt, weißt du, dass eine Relation verletzt wurde, aber du kennst immer noch nicht die korrekte Ausgabe. Du musst vom Verstoß gegen die Eigenschaft rückwärts auf den zugrunde liegenden Bug schließen, was mehr Arbeit sein kann.
Die Testdatengenerierung zählt. Zufällige Graphen, zufällige Sätze und zufällige Bilder sind nicht repräsentativ für reale Eingaben. Ein metamorpher Test auf synthetischen Daten kann bestehen, während das Produktionssystem bei Edge Cases versagt, die dein Generator nie erzeugt hat. Verwende Property-Based-Testing-Bibliotheken wie Hypothesis als Hilfe, aber bleibe deinen eigenen Generatoren gegenüber skeptisch.
Wie du heute damit anfängst
Du brauchst kein neues Framework. Du brauchst drei Dinge:
-
Wähle eine Funktion mit einem schwachen Oracle. Eine Modellinferenz-Methode, eine geometrische Berechnung, ein Simulationsschritt. Etwas, bei dem du aktuell nur testest „es stürzt nicht ab“ und dir wünschst, du könntest mehr tun.
-
Brainstorme drei Relationen. Frag dich: Welche Transformationen sollten die Ausgabe unverändert lassen? Welche Transformationen sollten die Ausgabe auf vorhersehbare Weise ändern? Welche Paare von Eingaben sollten verwandte Ausgaben erzeugen? Schreib sie auf, auch wenn sie offensichtlich erscheinen.
-
Implementiere eine Relation als Test. Führe ihn auf randomisierten Eingaben aus. Wenn er fehlschlägt, hast du einen Bug oder eine falsche Relation gefunden. Beides ist wertvoll.
Für das k-Means-Beispiel, das ich erwähnt habe, sieht ein korrekter Relations-Satz so aus:
import numpy as np
from sklearn.cluster import KMeans
def test_kmeans_translation_invariance():
X = np.random.rand(100, 3)
shift = np.array([10.0, -5.0, 2.0])
km1 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
km2 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X + shift)
# Centroids should differ by exactly the shift vector
np.testing.assert_allclose(km1.cluster_centers_ + shift, km2.cluster_centers_)
Dieser Test schlägt fehl, wenn die Clustering-Logik das Koordinatensystem falsch behandelt, und das ohne jemals zu behaupten, was die korrekten Zentroide sind.
Wenn du die Antwort nicht kennen kannst, teste die Struktur
Das Oracle-Problem ist kein Testing-Edge-Case. Es ist der Normalzustand für eine riesige Klasse nützlicher Software. Metamorphic Testing löst es nicht vollständig, aber es bringt dich von „Ich kann das nicht testen“ zu „Ich kann die Eigenschaften testen, die zählen“.
Beginne mit einer Relation, einer Funktion und einem echten Bug, den sie erwischt. Das reicht, um den Ansatz zu rechtfertigen. Alles andere ist nur das Hinzufügen weiterer Relationen.