cat posts/property-based-testing-hypothesis.md
Property-Based Testing mit Hypothesis
Beispieltests prüfen, woran Du gedacht hast. Hypothesis erzeugt Testdaten aus Strategien, sucht Gegenbeispiele und schrumpft Fehler auf kleine, nachvollziehbare Fälle.
In Agent im Loop (Teil 5) hielt Property-Based Testing die Schleife ehrlich. Statt immer neue interessante Beispiele von Hand zu sammeln, beschreibst Du, welche Eingaben erlaubt sind und welche Eigenschaften Dein Code für diese Eingaben erfüllen muss.
Hypothesis übernimmt dann die Suche nach einem Gegenbeispiel.
Das ist ein anderer Blick auf Tests: Nicht „Welche fünf Fälle sollte ich noch aufschreiben?“, sondern „Welche Aussage über meine Funktion müsste immer gelten?“
Beispiele vs. Eigenschaften
Ein klassischer Beispieltest legt Eingabe und erwartete Ausgabe fest:
def test_sortiert_drei_zahlen():
assert sorted([3, 1, 2]) == [1, 2, 3]
Das ist präzise, gut lesbar und als Dokumentation nützlich. Der Test sagt aber nichts über Listen aus, die Du nicht hingeschrieben hast.
Ein Property-Test formuliert stattdessen eine allgemeinere Eigenschaft:
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sortieren_ist_idempotent(xs):
assert sorted(sorted(xs)) == sorted(xs)
st.lists(st.integers()) beschreibt den Eingaberaum: Listen aus ganzen Zahlen.
@given erzeugt daraus Testfälle und ruft die Testfunktion immer wieder auf.
Standardmäßig versucht Hypothesis 100 gültige generierte Beispiele. Das können leere Listen sein, Listen mit Duplikaten, negativen Zahlen, sehr großen Werten oder vielen Elementen.
Wichtig ist die Formulierung „versucht“: Property-Based Testing beweist nicht mathematisch, dass die Eigenschaft für jede denkbare Eingabe gilt. Du formulierst eine universelle Aussage und Hypothesis sucht möglichst effektiv nach einem Gegenbeispiel.
Der interessante Teil beginnt beim Scheitern
Nehmen wir eine selbstgeschriebene Maximum-Funktion:
def naive_max(zahlen):
groesstes = 0
for zahl in zahlen:
if zahl > groesstes:
groesstes = zahl
return groesstes
Der offensichtliche Test ist grün:
def test_naive_max():
assert naive_max([3, 1, 2]) == 3
Vielleicht kommen noch ein paar Fälle dazu:
def test_naive_max_mit_duplikaten():
assert naive_max([4, 4, 2]) == 4
def test_naive_max_mit_null():
assert naive_max([0, 3, 1]) == 3
Immer noch grün.
Statt weitere Listen zu erfinden, können wir eine Referenz verwenden, der wir für
diesen Zweck vertrauen: Pythons eingebautes max().
@given(st.lists(st.integers(), min_size=1))
def test_naive_max_stimmt_mit_max(zahlen):
assert naive_max(zahlen) == max(zahlen)
Hypothesis findet schnell den Fall, den unsere Beispiele übersehen haben:
zahlen=[-1]
Für ausschließlich negative Werte ist der Startwert 0 falsch. Unsere Funktion gibt
0 zurück, obwohl diese Zahl überhaupt nicht in der Liste vorkommt.
Der eigentliche Fund kann zunächst aus einer wesentlich komplizierteren Eingabe bestehen. Hypothesis versucht anschließend, den Fehler zu shrinken: Elemente verschwinden, Zahlen werden vereinfacht und Strukturen kleiner, solange der Test weiterhin fehlschlägt.
Am Ende bleibt in diesem Fall ein leicht verständliches Gegenbeispiel wie:
[-1]
Das ist einer der größten praktischen Vorteile gegenüber einer Schleife mit selbst
erzeugten Zufallsdaten. Ein Fehler in einer Liste mit 200 merkwürdigen Zahlen hilft bei
der Diagnose wenig. [-1] erklärt den Bug praktisch selbst.
Hypothesis spricht dabei von einem vollständig geschrumpften beziehungsweise minimalen fehlschlagenden Testfall. Das bedeutet nicht, dass für jede denkbare Ordnung von Eingaben mathematisch das global kleinste Gegenbeispiel garantiert wird. Entscheidend ist, dass Hypothesis systematisch versucht, einen möglichst einfachen reproduzierbaren Fall zu finden.
Strategies beschreiben den Eingaberaum
Das Herzstück von Hypothesis sind Strategies. Sie beschreiben nicht einen einzelnen Wert, sondern eine Menge möglicher Werte.
Ein paar einfache Beispiele:
st.integers()
st.integers(min_value=1, max_value=100)
st.booleans()
st.text()
st.lists(st.integers())
st.tuples(st.integers(), st.text())
st.sampled_from(["rot", "gruen", "blau"])
Strategies lassen sich verschachteln:
personen = st.lists(
st.tuples(
st.text(min_size=1),
st.integers(min_value=0, max_value=130),
)
)
Damit erzeugt Hypothesis Listen aus Tupeln mit Name und Alter.
Der Eingaberaum sollte dabei möglichst das tatsächliche Datenmodell widerspiegeln. Wenn eine Funktion nur Portnummern akzeptiert, ist
st.integers(min_value=1, max_value=65535)
besser als
st.integers()
mit anschließendem Wegwerfen fast aller Werte.
Hypothesis kann besser suchen und shrinken, wenn gültige Eingaben direkt von der Strategy erzeugt werden.
Was ist eine gute Property?
Eine gute Property beschreibt Verhalten, ohne die Implementierung noch einmal im Test nachzubauen.
Für viele Arten von Code tauchen dieselben Muster immer wieder auf.
Roundtrip
Serialisierung und anschließendes Parsen sollten den ursprünglichen Wert ergeben:
@given(st.text())
def test_roundtrip(text):
assert decode(encode(text)) == text
Je nach Format kann die tatsächliche Eigenschaft etwas komplizierter sein. Wenn ein
Encoder normalisiert, lautet sie vielleicht nicht decode(encode(x)) == x, sondern
decode(encode(x)) == normalize(x).
Die Property muss zum Vertrag des Codes passen.
Idempotenz
Manche Operationen dürfen nach der ersten Anwendung nichts mehr verändern:
@given(st.lists(st.integers()))
def test_sorted_ist_idempotent(xs):
einmal = sorted(xs)
zweimal = sorted(einmal)
assert zweimal == einmal
Formatter, Normalisierer und Deduplizierung sind häufig gute Kandidaten für solche Tests.
Invarianten
Bestimmte Eigenschaften eines Ergebnisses bleiben immer erhalten:
@given(st.lists(st.integers()))
def test_sortieren_veraendert_die_laenge_nicht(xs):
assert len(sorted(xs)) == len(xs)
Das allein wäre für einen Sortieralgorithmus allerdings zu schwach. Eine Funktion, die einfach dieselbe Anzahl Nullen zurückgibt, würde ebenfalls bestehen.
Interessanter wird die Kombination mehrerer echter Anforderungen:
from collections import Counter
@given(st.lists(st.integers()))
def test_sortieren(xs):
ergebnis = sorted(xs)
assert all(
links <= rechts
for links, rechts in zip(ergebnis, ergebnis[1:])
)
assert Counter(ergebnis) == Counter(xs)
Jetzt prüfen wir sowohl die Reihenfolge als auch, dass keine Werte verschwinden oder hinzukommen.
Vergleich mit einer Referenz
Das Beispiel mit naive_max() benutzt eine sehr wirkungsvolle Technik: eine einfache
oder etablierte Implementierung als Orakel.
Angenommen, Du optimierst später einen Algorithmus:
@given(st.lists(st.integers()))
def test_schnelle_version_wie_referenz(xs):
assert schnelle_version(xs) == einfache_referenz(xs)
Die Referenz muss nicht schnell sein. Sie muss vor allem einfach genug sein, dass Du ihr vertraust.
Das ist auch beim Refactoring nützlich: Die alte Implementierung kann vorübergehend als Orakel für die neue dienen.
Grenzen
Wenn das Ergebnis einen definierten Bereich hat, lässt sich dieser direkt prüfen:
@given(st.integers(min_value=1, max_value=100))
def test_prozentwert_bleibt_im_bereich(wert):
ergebnis = berechne_prozentwert(wert)
assert 0 <= ergebnis <= 100
Bei der Würfel-Engine aus der Serie tauchte genau diese Art von Test früh auf: als Range-Test, der allein zu schwach war, aber als Mindestanforderung trotzdem seinen Platz hatte. Bestimmte Würfelausdrücke haben mathematisch bekannte Unter- und Obergrenzen.
„Stürzt nicht ab“
Auch das kann eine sinnvolle Property sein.
Bei einem Parser etwa:
@given(st.text())
def test_parser_hat_definiertes_fehlverhalten(text):
try:
parse(text)
except ParseError:
pass
Das Beispiel ist absichtlich vereinfacht. Die Idee dahinter: Beliebiger Input darf
entweder erfolgreich geparst oder mit der vorgesehenen Exception abgelehnt werden.
Ein IndexError, AssertionError oder anderer interner Fehler wäre dagegen ein Bug.
Besonders bei Parsern, Decodern und APIs mit großen Eingaberäumen findet diese Form überraschend viele Randfälle.
Ungültige Eingaben richtig ausschließen
Nicht jede theoretisch erzeugbare Eingabe ist für jede Funktion gültig.
Angenommen, wir testen Division:
@given(
st.integers(),
st.integers().filter(lambda n: n != 0),
)
def test_division(a, b):
assert (a // b) * b + (a % b) == a
Die Strategy für b schließt Null aus.
Alternativ gibt es assume():
from hypothesis import assume
@given(st.integers(), st.integers())
def test_division(a, b):
assume(b != 0)
assert (a // b) * b + (a % b) == a
assume(False) verwirft den aktuellen Testfall und lässt Hypothesis einen anderen
erzeugen.
Das ist praktisch für Bedingungen, die sich schlecht direkt in einer Strategy ausdrücken lassen. Es sollte aber nicht zum Standard werden, einen riesigen Eingaberaum zu erzeugen und davon 99 Prozent wegzuwerfen.
Wo möglich, beschreibst Du gültige Werte direkt in der Strategy. Das macht die Suche effizienter und vermittelt gleichzeitig klarer, welche Eingaben Deine Funktion überhaupt akzeptiert.
Konkrete Randfälle bleiben erlaubt
Property-Based Testing bedeutet nicht, dass Du nie wieder einen bestimmten Wert aufschreibst.
Hypothesis bietet dafür @example:
from hypothesis import example, given, strategies as st
@given(st.lists(st.integers(), min_size=1))
@example([-1])
def test_naive_max_stimmt_mit_max(zahlen):
assert naive_max(zahlen) == max(zahlen)
Der explizite Fall wird zusätzlich zu den generierten Fällen ausgeführt.
Das ist beispielsweise nützlich, wenn ein früherer Produktionsbug einen besonders wichtigen Regressionstest verdient. Du kannst ihn festhalten, ohne dafür einen zweiten Test mit fast identischem Body anzulegen.
Ob Du dafür @example oder einen normalen Beispieltest verwendest, ist vor allem eine
Frage der Lesbarkeit. Beides hat seinen Platz.
Wie viele Fälle sind genug?
Standardmäßig verwendet Hypothesis max_examples=100.
Für viele Unit-Tests ist das ein guter Ausgangspunkt. Bei sehr billigem Code und einem großen Suchraum kannst Du mehr ausprobieren:
from hypothesis import given, settings, strategies as st
@settings(max_examples=1000)
@given(st.lists(st.integers()))
def test_meine_property(xs):
...
Mehr Beispiele bedeuten allerdings auch mehr Laufzeit. Blind überall
max_examples=10000 einzutragen ist selten sinnvoll.
Außerdem ist max_examples nicht identisch mit der exakten Anzahl von Aufrufen Deiner
Testfunktion. Hypothesis kann Fälle verwerfen, früh einen Fehler finden, gespeicherte
Fehler erneut ausprobieren und beim Shrinking zusätzliche Aufrufe durchführen.
Das Ziel ist nicht eine beeindruckende Zahl im Testreport. Entscheidend sind ein guter Eingaberaum und eine Property, die falschen Code tatsächlich von richtigem unterscheidet.
Gefundene Fehler werden wieder ausprobiert
Hypothesis besitzt standardmäßig eine Example Database. Fehlgeschlagene Beispiele können dort gespeichert und bei späteren Testläufen erneut verwendet werden.
Das ist praktisch: Hat Hypothesis einen seltenen Fehler einmal gefunden, muss es nicht bei jedem Lauf darauf hoffen, denselben Weg durch den Suchraum wieder zufällig zu treffen.
Die Datenbank ersetzt trotzdem keinen Regressionstest, wenn ein bestimmter Bug dauerhaft
Teil der dokumentierten Testsuite werden soll. Dann ist ein normaler Beispieltest oder
@example(...) meist die klarere Aussage.
Property-Tests müssen reproduzierbar bleiben
Hypothesis kann seine eigenen generierten Eingaben reproduzieren und versucht fehlschlagende Beispiele erneut auszuführen. Das hilft aber nicht, wenn der getestete Code selbst von unkontrollierter Zeit, externem Zustand oder eigenem Zufall abhängt.
Problematisch wäre beispielsweise:
@given(st.integers())
def test_irgendwas(x):
assert verarbeite_mit_eigenem_zufall(x) > 0
Wenn verarbeite_mit_eigenem_zufall() bei identischem x einmal besteht und beim
nächsten Aufruf scheitert, kann Hypothesis kein stabiles Gegenbeispiel daraus machen.
Die Lösung ist dieselbe wie bei normalen Tests: solche Abhängigkeiten kontrollieren. Eine Zufallsquelle, Clock oder andere externe Dependency wird injiziert, statt tief im Code verborgen abgefragt zu werden.
Mehr dazu steht in Determinismus testen: Zufall, Zeit & I/O kontrollieren.
Falls Du gezielt die von Hypothesis erzeugte Folge reproduzieren möchtest, gibt es unter
anderem @seed(...) beziehungsweise bei pytest --hypothesis-seed. Ein Seed für
Hypothesis macht aber nicht automatisch Deinen gesamten getesteten Code deterministisch.
Hypothesis und pytest
Hypothesis integriert sich direkt in pytest:
@given(st.lists(st.integers()))
def test_sortieren(xs):
assert sorted(xs) == sorted(sorted(xs))
Auch die Kombination mit pytest.mark.parametrize wird unterstützt:
import pytest
@pytest.mark.parametrize("reverse", [False, True])
@given(st.lists(st.integers()))
def test_sortieren_in_beide_richtungen(reverse, xs):
ergebnis = sorted(xs, reverse=reverse)
assert len(ergebnis) == len(xs)
Für jeden parametrisierten Fall lässt Hypothesis also wieder generierte Eingaben laufen.
Bei pytest-Fixtures gibt es einen Punkt, der leicht überrascht: Eine function-scoped Fixture wird für den gesamten Hypothesis-Test aufgebaut, nicht für jeden einzelnen generierten Input neu.
Eine veränderliche Fixture wie
@pytest.fixture
def liste():
return []
solltest Du deshalb nicht so behandeln, als bekäme jeder von 100 Hypothesis-Fällen automatisch seine eigene frische Liste.
Hypothesis bricht solche Tests standardmäßig mit einem fehlgeschlagenen Health Check
ab (HealthCheck.function_scoped_fixture). Wer die Fixture bewusst über alle Beispiele
teilen will, muss ihn per
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) unterdrücken.
Für Zustand, der pro generiertem Beispiel frisch sein muss, ist es meist besser, ihn im
Test selbst anzulegen oder ebenfalls über eine Strategy zu erzeugen.
Schwache Properties geben falsche Sicherheit
Der häufigste Fehler bei Property-Based Testing ist nicht Hypothesis, sondern eine Property, die fast nichts verlangt.
Diese hier sieht vernünftig aus:
@given(st.lists(st.integers()))
def test_sortieren(xs):
assert isinstance(mein_sortierer(xs), list)
Eine Implementierung
def mein_sortierer(xs):
return []
besteht den Test perfekt.
Auch
assert len(mein_sortierer(xs)) == len(xs)
ist noch kein ausreichender Sortiertest.
Eine gute Gegenfrage beim Schreiben der Property lautet deshalb:
Welche offensichtlich falsche Implementierung könnte diesen Test trotzdem bestehen?
Wenn Dir schnell eine einfällt, ist die Property wahrscheinlich noch zu schwach.
Beispieltests verschwinden nicht
Property-Based Testing ersetzt klassische Tests nicht.
Ein Beispieltest wie
def test_preis_mit_19_prozent_mwst():
assert brutto(100, 19) == 119
ist ausgezeichnete Dokumentation. Man sieht sofort, welche Semantik erwartet wird.
Ein Property-Test beantwortet eine andere Frage:
@given(
st.integers(min_value=0),
st.integers(min_value=0, max_value=100),
)
def test_brutto_nie_kleiner_als_netto(netto, steuersatz):
assert brutto(netto, steuersatz) >= netto
Der eine hält einen konkreten Vertrag fest. Der andere sucht systematisch nach Eingaben, die eine allgemeinere Annahme verletzen.
Gerade zusammen sind beide stark: Beispiele dokumentieren wichtige Fälle, Properties durchkämmen den Raum dazwischen.
Das wird beim Arbeiten mit einem Coding-Agenten besonders interessant. Ein Agent kann einen kleinen Satz sichtbarer Beispieltests leicht überfitten: genau die erwarteten Werte produzieren, ohne die eigentliche Regel korrekt umzusetzen. Eine gute Property zwingt die Implementierung dagegen, sich für viele Varianten derselben Anforderung richtig zu verhalten.
Stolperfallen
- Die Property ist nicht der Code in anderer Schreibweise. Wenn Dein Test denselben Algorithmus noch einmal implementiert, können beide denselben Denkfehler enthalten. Suche lieber nach Invarianten, Roundtrips oder einer einfachen unabhängigen Referenz.
- Der Eingaberaum sollte zur Domain passen. Erzeuge möglichst gültige Eingaben
direkt mit Strategies, statt fast alles mit
assume()wieder wegzuwerfen. - Shrinking braucht stabile Fehler. Wenn dieselbe Eingabe mal besteht und mal scheitert, hast Du zuerst ein Determinismusproblem zu lösen.
- 100 Beispiele sind kein Beweis. Hypothesis sucht Gegenbeispiele sehr effektiv, kann aber bei großen oder unendlichen Eingaberäumen nicht jede Möglichkeit testen.
- Fixtures leben nicht automatisch pro Beispiel neu. Besonders bei veränderlichem Zustand in pytest-Fixtures kann das überraschende Wechselwirkungen erzeugen.
- Ein Property-Test darf konkret ergänzt werden. Wichtige bekannte Randfälle
gehören weiterhin in Beispieltests oder
@example.
Der eigentliche Perspektivwechsel ist klein: Hör für einen Moment auf, Testdaten zu sammeln, und frage stattdessen, welche Regeln Dein Code niemals verletzen darf. Hypothesis übernimmt dann den unangenehmen Teil und sucht nach den Eingaben, bei denen diese Behauptung nicht stimmt.
Weiterlesen
Sources: Hypothesis – Einführung, Hypothesis – Built-in Strategies, Hypothesis – Strategies anpassen, Hypothesis – Settings, Hypothesis – pytest-Integration.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.