Deine Unit Tests decken nur die Fälle ab, an die du dich erinnert hast
Du hast eine reverse-Funktion geschrieben. Du hast sie mit [1, 2, 3] und [4, 5, 6, 7] getestet. Sie besteht. Du veröffentlichst sie.
Ein Nutzer übergibt einen Slice mit einem einzigen Element. Deine Funktion verwirft ihn stillschweigend. Er öffnet ein Issue. Du starrt auf die Testdatei und fragst dich, wie du etwas so Offensichtliches übersehen konntest.
Du hast ihn übersehen, weil beispielbasiertes Testing nur Bugs findet, die du vorhersehen kannst. Jedes assert_eq! in deiner Test-Suite ist eine Vermutung darüber, wo der Fehler sich verstecken könnte. Property-Based Testing ersetzt diese Vermutungen durch zufällige Daten und mathematische Invarianten. Es findet den Bug beim einzelnen Element. Es findet auch den Bug beim leeren Vector, den Edge Case beim Integer Overflow und den Unicode-Corner-Case, von dem du nicht wusstest, dass er existiert.
Zufällige Inputs und Invarianten, nicht Beispiele und Erwartungen
Property-Based Testing dreht den Spieß um. Statt Inputs auszuwählen und exakte Outputs zu prüfen, definierst du eine Property, die für alle Inputs gelten muss, und das Test-Framework generiert hunderte zufälliger Inputs, um sie zu brechen.
Das klassische Beispiel ist das Umkehren einer Liste. Die Property ist nicht „reverse von [1, 2, 3] ist [3, 2, 1]“. Die Property lautet: „Eine Liste zweimal umzukehren ergibt die ursprüngliche Liste.“ Diese Invariante gilt für jede mögliche Liste, also wirft das Framework zufällige Listen darauf, bis etwas bricht oder das Iterationslimit erreicht wird.
Rust hat zwei ausgereifte Libraries dafür. quickcheck war die erste populäre Option, portiert aus Haskell. proptest ist der neuere Ansatz mit einem entscheidenden Feature: automatisches Shrinking. Wenn proptest einen fehlschlagenden Input findet, gibt es nicht einfach einen Vector mit hundert Elementen aus und lässt dich debuggen. Es vereinfacht den Input, bis es das kleinstmögliche Gegenbeispiel findet. Das ist der Unterschied zwischen dem Lesen eines fehlgeschlagenen Test-Outputs und dem tatsächlichen Verstehen des Bugs.
Wie Shrinking den echten Bug findet
Shrinking ist das Geheimrezept. Angenommen, proptest generiert einen zufälligen Vector mit 47 Integers und deine Property schlägt fehl. Der Bug ist nicht das 47. Element. Der Bug hat wahrscheinlich etwas mit leeren Vektoren, Vorzeichenwechseln oder doppelten Werten zu tun.
proptest weiß das. Es versucht, Elemente zu entfernen. Es versucht, Werte auf Null zu setzen. Es versucht, den Vector immer kleiner zu machen. Wenn der Test auch mit einem kleineren Input fehlschlägt, macht es mit dem Shrinking weiter. Es hört auf, wenn jede einfachere Variante besteht. Das Ergebnis ist das minimale Gegenbeispiel.
Das ist kein Nice-to-have. Es ist der Grund, warum Property-Based Testing in der Praxis nutzbar ist. Ohne Shrinking bist du wieder dabei, in einem Heuhaufen zu debuggen. Damit bekommst du die Nadel.
Ein echter Bug, ein echter Test
Hier ist eine reverse-Funktion mit einem subtilen Bug. Sieh mal, ob du ihn entdeckst, bevor der Test es tut.
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
let mut rev = Vec::with_capacity(xs.len());
for i in 1..xs.len() {
rev.push(xs[xs.len() - i].clone());
}
rev
}
Der Range beginnt bei 1 statt bei 0. Bei jeder nicht-leeren Liste wird das erste Element stillschweigend verworfen. Ein Unit Test mit [1, 2, 3] könnte bestehen, wenn der Autor nur geprüft hat, ob der Output umgekehrt aussieht, nicht ob er die richtige Länge hat. Aber die Property fängt ihn sofort ab.
Füge proptest zu deiner Cargo.toml hinzu:
[dev-dependencies]
proptest = "1.6"
Dann schreibe die Property:
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
let rev = reverse(&xs);
let rev_rev = reverse(&rev);
prop_assert_eq!(xs, rev_rev);
}
}
Führe cargo test aus, und der Output erzählt die Geschichte:
thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
left: `[0]`,
right: `[]`'
proptest hat einen fehlschlagenden zufälligen Vector auf ein einzelnes Element, [0], reduziert. Der erste Aufruf gibt [] zurück. Der zweite Aufruf gibt [] zurück. Die Property verlangt [] == [0], was fehlschlägt. Der Bug ist offensichtlich, sobald der Input minimal ist.
Du kannst auch strukturelle Properties direkt testen:
proptest! {
#[test]
fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
prop_assert_eq!(xs.len(), reverse(&xs).len());
}
}
Das schlägt mit demselben minimalen Input fehl, gibt aber einen noch klareren Fehler: Die Output-Länge ist 0, nicht 1.
Warum du trotzdem normale Unit Tests brauchst
Property-Based Testing ist kein Ersatz für beispielbasierte Tests. Es ist eine Ergänzung.
Unit Tests dokumentieren die Absicht. Wenn ein neuer Entwickler liest, dass reverse(&[1, 2, 3]) gleich [3, 2, 1] sein soll, lernt er, was die Funktion tun soll. Ein Property Test sagt „diese Invariante gilt“, aber er sagt nicht, wie der Output für einen normalen Fall aussieht.
Properties sind auch schwerer zu schreiben als Beispiele. State Machines, Side Effects und I/O lassen sich nicht sauber auf pure Funktionen abbilden. proptest hat Strategien für komplexe Typen, aber sie zusammenzubauen erfordert mehr Nachdenken als ein paar assert_eq!-Aufrufe.
Es gibt auch die Zeitkosten. Ein Property Test könnte tausend Iterationen laufen. In einer großen Codebase summiert sich das. Du lässt Property Tests in der CI laufen, nicht bei jedem Tastenanschlag während der Entwicklung.
Zufällige Tests können trotzdem reproduzierbar sein
Zufälligkeit macht Menschen nervös. Wenn ein Test nur manchmal fehlschlägt, liegt es am Code oder am Test?
proptest handhabt das, indem es fehlschlagende Seeds persistiert. Wenn eine Property fehlschlägt, speichert es den Seed und den reduzierten Input in eine Datei in proptest-regressions/. Der nächste Lauf spielt diesen exakten Fall ab, bevor neue zufällige Daten generiert werden. Ein flaky Property Test ist fast immer eine buggy Property, kein flaky Framework.
Wenn du strikten Determinismus brauchst, kannst du den Seed in deiner Test-Konfiguration festlegen. Die meisten Teams machen sich nicht die Mühe. Die Regression-Datei reicht aus.
Wo Property Tests sich auszahlen
Property-Based Testing ist den Overhead wert, wenn dein Code klare mathematische Invarianten und einen großen Input-Space hat.
Sorting ist das Lehrbuchbeispiel. Die Properties sind einfach: Der Output ist sortiert, er ist eine Permutation des Inputs und hat dieselbe Länge. Ein buggy Sort könnte handgeschriebene Beispiele bestehen, aber bei zufälligen Daten mit doppelten Elementen fehlschlagen.
Parser und Serializer sind ein weiterer Sweet Spot. Round-trip-Properties wie parse(serialize(x)) == x fangen Encoding-Bugs ab, die nur bei bestimmten Byte-Sequenzen auftauchen.
Alles mit Arithmetic, Collections oder State Transitions profitiert davon. Alles mit externen APIs, strict Timing oder menschlichem Urteilsvermögen nicht.
Fange mit einer Property an
Du musst deine Test-Suite nicht umschreiben. Such dir eine pure Funktion mit einer klaren Invariante aus. Füge proptest zu dev-dependencies hinzu. Schreibe eine einzelne Property. Führe sie aus, sieh zu, wie sie besteht, führe dann einen subtilen Bug ein und sieh zu, wie sie den Fehler auf etwas reduziert, das du in Sekunden debuggen kannst.
Sobald du siehst, wie ein zufälliger Test einen Bug findet, für den du nie ein Beispiel geschrieben hättest, wirst du öfter zu Properties greifen. Nicht für jeden Test. Nur für die, die zählen.