100 Testläufe sind eine Lüge: Wie du Property-Based Tests richtig dimensionierst
Wenn du Property-Based Tests mit den Standard-100 Beispielen ausführst, bekommst du das Schlechteste aus beiden Welten. Deine CI ist langsamer als nötig, und du erwischst immer noch nicht die Bugs, die zählen.
Die Zahl ist nicht magisch. Die meisten Libraries – Hypothesis eingeschlossen – nutzen 100 als Default, weil es eine runde Zahl ist, die sich sicher anfühlt. Aber „sich sicher anfühlen” ist keine Teststrategie.
Was Property-Based Testing wirklich verspricht
Property-Based Testing dreht das Skript bei Unit-Tests um. Statt Inputs und erwartete Outputs von Hand zu schreiben, definierst du eine Property. Eine Regel, die immer gelten sollte. Das Framework generiert Inputs, um sie zu brechen.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
assert lst == list(reversed(list(reversed(lst))))
Das Framework führt diese Funktion viele Male mit zufälligen Listen von Integers aus. Wenn es ein Gegenbeispiel findet, shrinkt es den Input auf die kleinste Version, die immer noch fehlschlägt. Eine 47-Element-Liste, die einen Bug triggert, ist fürs Debuggen wertlos. Eine 3-Element-Liste ist Gold.
Das ist mächtig. Es ist auch probabilistisch. Property-Based Testing kann Korrektheit nicht beweisen. Es kann nur dein Vertrauen erhöhen, dass ein Bug nicht existiert – oder einen Bug finden, wenn einer existiert. Dieser probabilistische Charakter ist es, der die Anzahl der Testläufe so wichtig macht.
Warum 100 willkürlich ist
Seien wir ehrlich darüber, woher 100 kommt. In Hypothesis ist es ein Default, der 2015 gewählt wurde, weil es eine schöne runde Zahl war, die die meisten Bugs erwischte, ohne Tests unerträglich langsam zu machen. Es war ein sozialer Kompromiss, kein statistischer.
Die Wahrscheinlichkeit, einen Bug zu finden, hängt von zwei Dingen ab. Wie häufig der Bug im Input Space vorkommt, und wie viele Samples du nimmst. Wenn ein Bug nur triggert, wenn der Input ein Palindrom der Länge größer als 20 ist, und Palindrome 0,01 % aller Listen ausmachen, gibt dir 100 Läufe ungefähr eine 1-%-Chance, ihn zu erwischen. Das ist kein Test. Das ist ein Lotterielos.
Die meisten Bugs sind nicht so selten. Viele Properties brechen auf leeren Listen, einzelnen Elementen oder einfachen Duplikaten. Ein gut getunter Generator erwischt die schnell. Aber der Default von 100 geht davon aus, dass deine Generatoren perfekt sind und deine Bugs oberflächlich. Beide Annahmen sind falsch.
Was die Anzahl der Läufe dir statistisch wirklich bringt
Wenn wir Bug-Discovery als Sampling with replacement aus einem Input Space modellieren, in dem der Bug mit Wahrscheinlichkeit p auftritt, ist die Wahrscheinlichkeit, den Bug nach n Läufen zu verpassen, (1 - p)^n.
Für p = 0,01 gibt dir 100 Läufe eine 37-%-Chance, den Bug zu verpassen. Für p = 0,001 gibt dir 100 Läufe eine 90-%-Chance, ihn zu verpassen. Um 99-%-Confidence zu haben, einen 0,1-%-Bug zu erwischen, brauchst du etwa 4.600 Läufe.
import math
def runs_for_confidence(p, confidence=0.99):
"""Returns the runs needed to catch a bug with probability `p`
at the given confidence level."""
return math.ceil(math.log(1 - confidence) / math.log(1 - p))
print(runs_for_confidence(0.01)) # 459
print(runs_for_confidence(0.001)) # 4603
print(runs_for_confidence(0.0001)) # 46050
Das ist der Teil, der Menschen unwohl macht. Wenn du hohe Confidence in seltene Bugs willst, brauchst du Zehntausende von Läufen. Niemand will in der CI so lange warten.
Shrinking verändert die Kostenrechnung
Der 100-Run-Default wurde gesetzt, bevor Shrinking so gut war wie heute. Moderne Property-Based Testing Frameworks finden nicht nur Bugs. Sie finden minimale Bugs.
Das bedeutet, du kannst in Begriffen von Budget statt nur von Count denken. Wenn du 1.000 Beispiele ausführst und auf dem 847. Lauf einen Bug findest, kann Shrinking weitere 200 bis 300 Ausführungen brauchen, um das Gegenbeispiel zu minimieren. Die Gesamtkosten sind 1.100 oder mehr Läufe für einen Bug. Aber wenn du 10.000 Beispiele ausführst und nichts findest, hast du 10.000 Läufe für Seelenfrieden ausgegeben.
Der Trick ist, Discovery von Validation zu trennen. Führe eine kleine, schnelle Suite in der CI aus für sofortiges Feedback. Führe eine größere, langsamere Suite über Nacht oder auf Release-Branches aus für tiefere Confidence.
from hypothesis import given, settings, strategies as st
import json
# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
assert json.loads(json.dumps(d)) == d
# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
assert json.loads(json.dumps(d)) == d
Das geht nicht nur um Speed. Es geht um Informationsdichte. Ein 100-Lauf-Test, der passed, sagt dir fast nichts. Ein 5.000-Lauf-Test, der passed, sagt dir etwas mehr. Ein 100-Lauf-Test, der failed, sagt dir genau, wo du hinsehen musst.
Wie wir Property-Based Tests in Tiers aufteilen
Nach unserer Erfahrung ist der beste Ansatz, aufzuhören, alle Properties als gleich zu behandeln. Wir teilen sie in drei Tiers auf.
Fast Properties laufen bei jedem Pull Request. Das sind die mechanischen. Round-trip-Serialisierung, Idempotenz von Deduplizierung, grundlegende Invarianten auf Datenstrukturen. Wir führen 100 bis 200 Beispiele aus. Sie sind in unter einer Sekunde durch.
Deep Properties laufen bei jedem Merge in main. Die zielen auf komplexe State Machines, Event-Processing-Pipelines und alles mit kombinatorischer Explosion ab. Wir führen 2.000 bis 10.000 Beispiele aus. Sie dauern Minuten, keine Stunden.
Exploratory Properties laufen manuell vor Releases. Das sind die, bei denen wir max_examples auf 50.000 oder mehr drehen und die Maschine rechnen lassen, während wir das Changelog reviewen. So haben wir Race Conditions und Integer-Overflow-Edge-Cases gefunden, die kein Amount an Unit Testing erwischt hätte.
Was du statt Raten tun solltest
Hör auf, max_examples als einen Regler zu behandeln, den du einmal einstellst und vergisst. Behandle ihn als eine Konfiguration, die zur Property gehört, nicht zum Framework.
Stelle drei Fragen für jede Property, die du schreibst.
Wie teuer ist dieser Test auszuführen? Wenn jedes Beispiel 50 ms dauert, sind 10.000 Läufe 8 Minuten. Wenn es 5 ms dauert, ist es unter einer Minute.
Wie schlimm ist der Bug, wenn wir ihn verpassen? Ein Formatierungsbug in einer Log-Nachricht ist nicht dasselbe wie ein Datenkorruptionsbug in einer Payment-Pipeline.
Wie selten ist die Trigger-Bedingung? Wenn der Bug nur in Schaltjahren auftritt, oder wenn zwei UUIDs kollidieren, oder genau bei INT_MAX, brauchst du mehr Läufe oder einen smarteren Generator.
Smartere Generatoren schlagen fast immer mehr Läufe. Wenn du einen JSON-Parser testest, generiere keine zufälligen Strings und hoffe, dass sie parsen. Generiere valide Objekte und mutiere sie dann.
from hypothesis import given, settings, strategies as st
import json
# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
try:
json.loads(s)
except json.JSONDecodeError:
pass # Most inputs hit this immediately
# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
assert json.loads(json.dumps(d)) == d
500 Läufe mit einem guten Generator schlagen 10.000 Läufe mit einem schlechten. Jedes Mal.
Häufige Fragen zur Dimensionierung von Property-Based Tests
Bedeuten mehr Läufe nicht immer bessere Coverage?
Nicht unbedingt. Property-Based Testing hat im traditionellen Sinne keinen Coverage-Metric. Mehr Läufe erhöhen die Wahrscheinlichkeit, einen Bug zu finden, aber abnehmende Erträge setzen schnell ein. Von 100 auf 200 zu verdoppeln, ist bedeutsam. Von 10.000 auf 20.000 zu verdoppeln, selten.
Was ist mit Fuzzing? Ist das nicht einfach Property-Based Testing mit Millionen von Läufen?
Fuzzing ist verwandt, aber anders. Fuzzer führen typischerweise Millionen von Inputs ohne semantisches Verständnis der Domain aus. Property-Based Testing nutzt strukturierte Generatoren und Shrinking. Du kannst PBT als smartes Fuzzing betrachten, oder Fuzzing als Brute-Force-PBT. Die Run-Count-Kalkulation ist anders, weil die Kosten pro Lauf und die Information pro Lauf nicht dieselben sind.
Sollte ich max_examples für die CI höher oder niedriger setzen?
Höher für die CI, niedriger für die lokale Entwicklung. Dein Laptop ist für Speed. Deine CI ist für Confidence. Nutze ein Settings-Profil oder eine Umgebungsvariable, um zwischen den beiden zu wechseln.
import os
from hypothesis import settings
CI = os.environ.get("CI", "false").lower() == "true"
settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)
settings.load_profile("ci" if CI else "dev")
Wie weiß ich, ob mein Generator gut genug ist?
Führe deinen Test mit max_examples auf einen sehr hohen Wert gesetzt aus, sagen wir 50.000, und beobachte den Coverage-Report. Wenn Branches fehlen, übt dein Generator sie nicht aus. Fixe den Generator, bevor du die Anzahl der Läufe senkst.
Hör auf, nach der perfekten Anzahl an Läufen zu suchen, und fang an zu messen
Es gibt keine universell richtige Zahl an Testläufen für Property-Based Testing. Es gibt nur die richtige Zahl für deine Property, deine Generatoren, dein CI-Budget und die Kosten des Bugs, den du verhindern willst.
Fang mit 100 an, wenn du musst. Aber erhöhe sie für Properties, die kritische Pfade beschützen, und senke sie für Properties, die nur Sanity Checks sind. Miss, wie lange deine Tests dauern. Profile deine Generatoren. Und denk daran: Ein Property-Based Test, der 100 Mal passed, ist kein Beweis. Es ist nur ein Indiz.
Wenn du tiefer einsteigen willst, ist die Dokumentation von Hypothesis zu Test-Statistics und Targeted Property-Based Testing lesenswert. Die hypothesis CLI kann dir genau zeigen, auf welchen Beispielen deine Tests Zeit verbringen. Das ist der erste Ort, an dem du hinschauen solltest, wenn du entscheidest, ob du den Regler hoch- oder runterdrehst.