Zum Inhalt springen

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!