Ich möchte heute etwas über Property-based Testing erzählen.
Beginnen wir mit einer kleinen Geschichte.
Das ist Stefan.
Stefan freut sich.
Denn Stefan mag Unit-Tests.
Er schreibt seine Software sogar testgestrieben.
Für Stefan gibt es nichts Schöneres als einen grünen Balken in Eclipse.
Aber bei einigen Aufgaben ist Stefan auch mal genervt von den ganzen Tests, die er schreiben muss.
Neulich musste Stefan z.B. einen Algorithmus zum Ermitteln der Primfaktoren einer Zahl programmieren.
Dabei werden Zahlen in ihre Primfaktoren zerlegt. Jede natürliche Zahl kann als eindeutiges Produkt aus Primzahlen dargestellt werden.
Der Algorithmus selbst ist nicht allzu komplex.
Aber Stefan musste dafür mehrere Tests schreiben, die alle sehr ähnlich aussahen.
Und trotzdem beschlich Stefan das ungute Gefühl, dass er vielleicht noch einen wichtigen Test vergessen haben könnte.
Außerdem mag Stefan keinen doppelten Code.
Aber die Assertions in eine einzige Methode zu packen, gefällt ihm auch nicht…
…da beim ersten Fehler die übrigen Assertions nicht mehr durchgeführt werden.
Parametrisierte Tests wären dafür zwar eine gute Lösung…
…aber die Beispiele muss Stefan sich trotzdem noch selbst ausdenken.
5min
Warum kann man sich die Testfälle nicht einfach generieren lassen? Das muss doch möglich sein!
Zufallszahlen zu generieren ist tatsächlich kein Problem. Aber woher soll der Test jetzt wissen, was das erwartete Ergebnis ist?
Vielleicht müssen die Tests einfach komplett anders entwickelt werden, um zufällige Eingangswerte nutzen zu können.
Und genau darum soll es heute gehen: Wie ersetze ich meine manuellen Example-based Tests durch generierte Property-based Tests?
Example-based
Testfälle mit erwartetem Ergebnis ausdenken
Testet nur die definierten Werte
Testet konkrete Ergebnisse des Algorithmus
Property-based
Testfälle werden generiert
Testet potentiell alle möglichen Eingangswerte
Testet allgemeine Eigenschaften des Algorithmus
PBT kommt ursprünglich aus der funktionalen Programmierung. Eines der ersten Frameworks war Quickcheck für Haskell.
Aber keine Angst, PBT kann inzwischen auch mit „normalen“ Sprachen verwendet werden.
Ich verwende im Folgenden JUnit-Quickcheck für Java, aber es gibt auch Frameworks für viele andere Sprachen.
Schauen wir uns ein Beispiel in JUnit-Quickcheck an.
Der simple zu testende Code ist ein Alterscheck.
Ein (nicht so sinnvoller) Test mit JUnit-Quickcheck könnte so aussehen.
10min
Das Ergebnis ist entsprechend…
Aber für die Fehlersuche ist der Wert jetzt nicht allzu hilfreich.
Was hier helfen kann ist „Shrinking“.
Das Framework versucht nun, den Eingangswert auf den „kleinsten“ Wert zu reduzieren, der einen Fehlschlag produziert.
Und tatsächlich wird die magische Grenze von 130 schnell gefunden.
Man kann dem Framework aber auch gleich sagen, welche Werte verwendet werden sollen.
Die Frameworks haben aber auch noch weitere interessante Funktionen.
Weitere Einschränkungen für die generierten Werte
Seeds für reproduzierbare Tests
Generatoren für Basistypen
Generatoren für eigene Typen sind möglich
10min
Ok, die Frameworks sind ganz toll. Aber kann ich PBT jetzt wirklich einfach so einsetzen? Komm zum Punkt!
Es eignen sich nicht alle Algorithmen für PBT, da es allgemeine Eigenschaften geben muss, gegen die man testen kann.
Die Primfaktorzerlegung ist ein gutes Beispiel für einen mit PBT testbaren Algorithmus, da ziemlich einfach für jede Eingangszahl geprüft werden kann, ob das Ergebnis korrekt ist.
Um zu prüfen, ob die Zerlegung korrekt ist, kann man einfach das Produkt der Faktoren bilden und es mit der Eingangszahl vergleichen.
Um zu prüfen, ob die Zerlegung korrekt ist, kann man einfach das Produkt der Faktoren bilden und es mit der Eingangszahl vergleichen.
So könnte das Ganze dann als Test aussehen.
Zusätzlich könnte man noch testen, dass die Primfaktoren selbst nicht weiter zerlegbar sind.
So könnte das Ganze dann als Test aussehen.
Die Primfaktorzerlegung lässt sich also anscheinend gut mit PBT testen.
Aber für welche Algorithmen gilt das denn nun ganz allgemein?
Wenn es zum zu testenden Algorithmus eine Umkehroperation gibt, kann man diese nutzen, um allgemeingültige Tests zu schreiben.
Hier ist ein Beispiel für eine Serialisierung zu JSON und die Prüfung gegen die Deserialisierung des Ergebnisses.
15min
Wenn man Operationen verketten kann, sollten unterschiedliche Reihenfolgen in vielen Fällen zum gleichen Ergebnis führen.
Wenn man z.B. eine Liste aus Personen nach Geschlecht und Alter filtert, sollte die Reihenfolge der Filter egal sein.
So könnte ein entsprechender Test aussehen.
15min
Bestimmte Eigenschaften von Ergebnissen sind bei einigen Algorithmen immer gleich, unabhängig vom konkreten Ergebnis.
Hashes eines bestimmten Algorithmus haben z.B. immer die gleiche Länge, unabhängig vom Eingangswert.
SHA256-Hashes haben gewisse Eigenschaften, die immer gelten müssen.
Bestimmte Operationen sollten idempotent sein, also bei mehrfachem Aufruf immer das gleiche Ergebnis liefern.
Eine bereits sortierte Liste sollte auch nach erneutem Sortieren sortiert bleiben.
So könnte ein Test für die Sortierung aussehen.
Manchmal hat man vielleicht eine Referenzimplementierung, gegen die man seinen eigenen Algorithmus prüfen kann.
Zur Ermittlung des größten gemeinsamen Teilers zweier Zahlen kann man z.B. die Primfaktorzerlegung nutzen oder den Algorithmus von Euklid.
So könnte dann ein Test aussehen.
20min
Damit sind wir schon beim Fazit angelangt.
Schon bei wenigen möglichen Parametern explodieren die Kombinationsmöglichkeiten für automatisierte Tests.
Um nichts zu vergessen, ist eine Generierung möglicher Werte und Kombinationen hilfreich.
Man spart sich mühsame Tests von Edge Cases und kann nichts vergessen.
Trotzdem enthalten die Tests keinen doppelten Code mehr und sind sogar allgemeingültiger.
Das macht sich auch in einer entsprechenden Code Coverage bemerkbar.
Sinnvoll ist also vielleicht eine Mischung aus Example- und Property-based Tests.
Beim TDD erstellt man wahrscheinlich eher EBTs und im Anschluss dann PBTs zur Absicherung.
Stefan kann sich also wieder über seine Arbeit freuen und das beste aus beiden Welten nutzen!