Du hast zwölf metamorphische Relations für deine Preisberechnungsengine geschrieben. Jeder Test besteht. Du fühlst dich gut abgedeckt.
Dann meldet ein Kunde, dass Mengenrabatte verkehrt herum berechnet werden. Du prüfst deinen Relations-Satz. Kein einziger Test ist fehlgeschlagen. Du hattest Relations für additive Konsistenz, Monotonie und Idempotenz. Keine von ihnen hat einen Vorzeichenfehler im Rabattmultiplikator erwischt.
Das ist das dunkle Geheimnis des Metamorphic Testing: Relations zu haben ist nicht dasselbe wie nützliche Relations zu haben. Eine schwache metamorphische Relation ist schlimmer als gar kein Test, weil sie dich davon überzeugt, dass dein Code korrekt ist, wenn er das nicht ist.
Was macht eine Relation „gut“?
Eine gute metamorphische Relation hat eine hohe Fehlererkennungsfähigkeit. Sie erwischt echte Bugs, die Programmierer tatsächlich schreiben. Der Rest ist nur Overhead.
Das klassische Beispiel ist das Testen einer Mittelwertfunktion mit einem Off-by-One-Bug:
def buggy_mean(values):
"""Compute the arithmetic mean."""
return sum(values) / (len(values) - 1) # bug: off-by-one in denominator
Das sieht plausibel aus, wenn man an Formeln für die Stichprobenvarianz gewöhnt ist. Ist es aber nicht. Hier sind vier Relations, die Leute häufig für eine Mittelwertfunktion schreiben, und was jede davon tatsächlich erwischt:
-
Beschränktheit: Der Mittelwert liegt zwischen Minimum und Maximum. Schwach. Der fehlerhafte Mittelwert erfüllt das für die meisten Eingaben immer noch.
-
Idempotenz bei Konstanten:
mean([c] * n) == c. Mittel. Sie erwischt den Bug für konstante Listen, aber Zufallsdaten lösen selten einen Fehler aus. -
Translationsinvarianz:
mean([x + c for x in values]) == mean(values) + c. Stark. Der fehlerhafte Nenner bricht das für fast jede nicht-leere Eingabe. -
Skalierung:
mean([x * k for x in values]) == mean(values) * k. Stark. Derselbe Grund. Der Off-by-One überlebt die Skalierung in genau null interessanten Fällen.
Wenn deine Test-Suite nur Beschränktheit und Konstanten-Idempotenz geprüft hätte, wäre der Off-by-One in die Produktion gewandert. Du hättest metamorphe Tests gehabt. Du hättest aber keine Fehlererkennung gehabt.
Starke Relations vs. schwache Relations
Der Unterschied zwischen einer starken und einer schwachen Relation ist nicht, wie clever sie klingt. Es ist, wie viele Fehlerklassen sie eliminiert.
Eine schwache Relation prüft eine Eigenschaft, die die meisten falschen Implementierungen ohnehin zufällig erfüllen. Beschränktheit ist ein perfektes Beispiel. Die meisten arithmetischen Bugs bewahren die Beschränktheit, weil Addition und Multiplikation nicht spontan Werte außerhalb des Eingabebereichs erfinden. Eine Relation, die für kaputten Code besteht, ist Theater.
Eine starke Relation kodiert eine strukturelle Einschränkung, die fehlerhafte Implementierungen verletzen. Translationsinvarianz ist stark, weil sie die Eingabetransformation über eine exakte Gleichheit mit der Ausgabetransformation verknüpft. Es gibt keinen Spielraum.
Du kannst das formal messen. In der Metamorphic-Testing-Forschung bedeutet Relations-Subsumption, dass Relation A jeden Fehler erkennt, den Relation B erkennt, plus einige. Wenn A B subsumiert, ist B redundant. Du solltest A behalten und B löschen.
In der Praxis brauchst du den formalen Beweis nicht. Du brauchst die Intuition: Wenn eine Relation noch bestehen würde, nachdem du absichtlich einen plausiblen Bug eingeführt hast, ist sie schwach. Schmeiß sie raus.
Gute Relations decken verschiedene Fehlerdomänen ab
Eine starke Relation reicht nicht. Eine einzelne Relation erwischt eine Klasse von Fehlern. Reale Programme enthalten mehrere unabhängige Bug-Typen, und dein Relations-Satz muss sie abdecken.
Betrachte eine Sortierfunktion. Hier sind Relations, sortiert nach dem, was sie erwischen:
Permutation: Die Ausgabe enthält exakt dieselben Elemente wie die Eingabe. Erwischt Drop-/Duplicate-Bugs. Verpasst Ordering-Bugs.
Ordnung: Die Ausgabe ist nicht absteigend. Erwischt Comparison-Bugs. Verpasst Permutation-Bugs.
Idempotenz: sort(sort(x)) == sort(x). Erwischt nur wirklich kaputte Implementierungen, die Sortiertheit zerstören. Fast nutzlos.
Stabilität: Wenn du jedes Element mit seinem ursprünglichen Index paarst, bleiben gleiche Schlüssel in der Eingabereihenfolge. Erwischt Comparison-Operatoren, die >= statt > verwenden.
Substruktur: Ein Präfix zu sortieren und dann die volle Liste zu sortieren sollte im Präfix übereinstimmen. Erwischt Early-Termination-Bugs.
Eine Test-Suite mit nur Permutation und Idempotenz würde einen Sortierer verpassen, der immer [1, 2, 3] zurückgibt. Eine Suite mit Permutation und Ordnung erwischt diesen Bug. Füge Stabilität hinzu, und du erwischst auch instabile Sortierer.
Der Punkt ist nicht, so viele Relations wie möglich zu sammeln. Der Punkt ist, unabhängige Fehlermodi abzudecken. Zwei Relations, die denselben Bug erwischen, sind schlechter als eine Relation, die einen anderen Bug erwischt.
Der Kompromiss: Stärkere Relations sind schwieriger zu finden
Es gibt einen Grund, warum Teams schwache Relations schreiben. Starke Relations erfordern Domänenwissen. Du musst die mathematische Struktur deines Problems gut genug verstehen, um eine nicht-offensichtliche Invariante zu kodieren.
Für die Mittelwertfunktion ist Translationsinvarianz für jeden mit Statistik-Hintergrund offensichtlich. Für eine Teilchensimulation könnte die entsprechende Relation das Wissen erfordern, dass Hamiltonsche Dynamik das Phasenraumvolumen bewahren. Nicht jedes Team hat dieses Fachwissen zur Hand.
Der andere Kostenpunkt ist das Debuggen. Wenn eine starke Relation fehlschlägt, sagt dir die Verletzung, dass eine strukturelle Eigenschaft gebrochen ist, aber der Bug könnte überall in der Kette des Schlussfolgerns liegen, die zu dieser Eigenschaft geführt hat. Eine schwache Relation wie „Ausgabelänge gleich Eingabelänge“ versagt auf genau eine Weise. Eine starke Relation wie „die Fourier-Transformation eines verschobenen Signals erhält einen linearen Phasenterm“ versagt auf hundert Weisen, und herauszufinden, welche davon dein Bug ist, dauert länger.
Das ist die zentrale Spannung. Schwache Relations sind leicht zu schreiben, leicht zu debuggen und meist nutzlos. Starke Relations sind schwer zu schreiben, schwer zu debuggen und finden tatsächlich Bugs. Es gibt keinen kostenlosen Lunch.
Wie man eine metamorphische Relation evaluiert
Bevor du eine Relation zu deiner Test-Suite hinzufügst, führe sie durch drei Checks:
Der gezielte Bug-Test. Führe einen realistischen Bug in deiner Implementierung ein. Schlägt die Relation fehl? Wenn nicht, zieht die Relation nicht ihr Gewicht. Probiere einen Vorzeichenfehler, einen Off-by-One, vertauschte Argumente, eine fehlende Grenzbedingung. Das sind die Bugs, die in der Produktion passieren. Deine Relations sollten sie erwischen.
Der Unabhängigkeitstest. Schau dir deine bestehenden Relations an. Würde eine von ihnen denselben Bug erwischen? Wenn ja, ist diese neue Relation redundant. Redundanz ist keine Sicherheit. Sie ist Wartungslast ohne marginalen Nutzen.
Der Falsifizierbarkeitstest. Kannst du dir eine plausibel kaputte Implementierung vorstellen, die die Relation erfüllt? Wenn du eine in dreißig Sekunden skizzieren kannst, ist die Relation zu schwach. Eine gute Relation sollte sich wie eine enge Einschränkung anfühlen, nicht wie eine vage Empfehlung.
So sieht das in Code für die Mittelwertfunktion aus:
import random
def mean(values):
return sum(values) / len(values)
def test_translation_invariance():
values = [random.uniform(-100, 100) for _ in range(20)]
c = 5.5
shifted = [x + c for x in values]
assert mean(shifted) == mean(values) + c
def test_scaling():
values = [random.uniform(-50, 50) for _ in range(20)]
k = 3.0
scaled = [x * k for x in values]
assert mean(scaled) == mean(values) * k
Führe jetzt den Off-by-One-Bug ein. Ändere len(values) zu len(values) - 1. Führe beide Tests aus. Translationsinvarianz schlägt sofort fehl. Skalierung schlägt sofort fehl. Beschränktheit würde wahrscheinlich bestehen.
Das ist der Unterschied zwischen einer Relation, die ihren Platz in deiner Suite verdient, und einer, die nur Zeilen verbraucht.
Beginne mit Fehlerklassen, nicht mit Eigenschaften
Der Fehler, den die meisten Teams machen, ist, zuerst Eigenschaften zu brainstormen. Sie fragen: „Welche Invarianten hat diese Funktion?“ Das produziert schwache Relations, weil Invarianten leicht zu formulieren und schwer zu verletzen sind.
Frage stattdessen nach Fehlerklassen. „Welche Bugs würde ein übermüdeter Programmierer in dieser Funktion schreiben?“ Dann finde Relations, die diese Bugs erwischen.
Für eine geometrische Distanzfunktion sind die wahrscheinlichen Bugs Vorzeichenfehler, Verwechslungen von Einheiten und Dimensionsfehler. Eine Relation, die prüft, dass die Distanz nicht negativ ist, erwischt Vorzeichenfehler. Eine Relation, die Skalierung unter Koordinatentransformationen prüft, erwischt Verwechslungen von Einheiten. Eine Relation, die die Dreiecksungleichung prüft, erwischt Dimensions-Unsinn.
Wenn du den Bug nicht benennen kannst, den eine Relation erwischt, brauchst du diese Relation nicht.
Relations sind eine knappe Ressource. Gib sie weise aus.
Metamorphic Testing dreht sich nicht um Coverage-Metriken. Es dreht sich um Vertrauen. Eine starke Relation, die echte Bugs erwischt, ist mehr wert als zwanzig schwache Relations, die für kaputten Code bestehen.
Auditiere deine bestehenden metamorphen Tests. Führe einen Bug ein. Sieh, was fehlschlägt. Lösche, was nicht fehlschlägt. Dann füge für jede Fehlerklasse, die dir tatsächlich Sorgen macht, eine Relation hinzu. Das ist eine Test-Suite, die sich bezahlt macht.