Testen für Fortgeschrittene in Rust: Mocks, Property-Tests und Benchmarks
Du weißt schon, wie du einen Test mit #[test] und assert_eq! schreibst (falls nicht, fang mit Testen in Rust an). Für kleine Programme reicht das. Aber in echten Projekten tauchen schnell neue Fragen auf. Wo sollen die Tests liegen? Wie testest du Code, der E-Mails verschickt oder die Uhrzeit abfragt? Wie findest du genau die eine Eingabe, an die du nicht gedacht hast? Und ist deine „schnellere“ Version wirklich schneller?
Stell dir eine Autowerkstatt vor. Einfache Tests prüfen, ob jedes Teil funktioniert. Diese Seite ergänzt den Rest der Werkstatt: einen Prüfstand für Teile (Fakes und Mocks), eine Maschine, die das Auto über Tausende zufällige Schlaglöcher rüttelt (Property-Tests), ein Foto davon, wie alles beim letzten Mal aussah (Snapshots), und eine Stoppuhr, der du vertrauen kannst (Benchmarks).
Eine ehrliche Anmerkung vorweg: Der Run-Button im Rust Playground führt main aus, nicht die Tests. Außerdem gibt es dort die Test-Crates nicht, die wir weiter unten verwenden. Deshalb zeigen die meisten Beispiele hier echte Ausgaben von cargo test und cargo bench aus einem kleinen Projekt auf einem normalen Rechner. Wenn ein Programm ein main hat, gibt es wie gewohnt einen „Selbst ausprobieren“-Button.
Tests organisieren
Abschnitt betitelt „Tests organisieren“So ist die kleine Bibliothek shop aufgebaut, die wir auf dieser Seite verwenden:
shop/├── Cargo.toml├── src/│ ├── lib.rs <- Code + Unit-Tests in `mod tests`│ └── orders.rs├── tests/│ ├── checkout.rs <- ein Integrationstest│ └── common/│ └── mod.rs <- Hilfsfunktionen für alle Integrationstests└── benches/ └── stock.rs <- ein BenchmarkRust kennt drei Arten von Tests, und cargo test führt sie alle aus:
- Unit-Tests (Modultests) stehen direkt neben dem Code, in einem Block
#[cfg(test)] mod tests. Sie sehen auch private Funktionen. - Integrationstests liegen im Ordner
tests/. Jede Datei wird als eigenes Crate kompiliert, das deine Bibliothek von außen benutzt – genau wie ein Nutzer. Deshalb können sie nurpub-Elemente aufrufen. - Doc-Tests sind die Codebeispiele in
///-Kommentaren. Sie beweisen, dass deine Dokumentation noch stimmt. Die Seite Dokumentation in Rust erklärt sie ausführlich.
Hier ist ein Teil von src/lib.rs. Achte auf den Doc-Test bei apply_discount und auf die Hilfsfunktionen (Helper) und Fixtures in mod tests:
/// Eine Zeile im Warenkorb. Preise sind in Cent.#[derive(Debug, Clone, PartialEq)]pub struct Item { pub name: String, pub price_cents: u32, pub qty: u32,}
pub fn cart_total(items: &[Item]) -> u32 { items.iter().map(|i| i.price_cents * i.qty).sum()}
/// Zieht `percent` Prozent von einem Preis ab.////// ```/// use shop::apply_discount;////// assert_eq!(apply_discount(1000, 25), 750);/// ```pub fn apply_discount(price_cents: u32, percent: u32) -> u32 { assert!(percent <= 100, "discount must be at most 100%, got {percent}%"); price_cents - price_cents * percent / 100}
/// Macht aus "12.50" 1250 Cent.pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Macht aus 1250 Cent "12.50".pub fn format_price(cents: u32) -> String { format!("{}.{:02}", cents / 100, cents % 100)}
#[cfg(test)]mod tests { use super::*;
// Ein Helper: kurz aufzurufen, und alle Tests bauen Items auf die gleiche Weise. fn item(name: &str, price_cents: u32, qty: u32) -> Item { Item { name: name.to_string(), price_cents, qty } }
// Eine Fixture: ein realistischer Warenkorb, den mehrere Tests teilen. fn sample_cart() -> Vec<Item> { vec![item("coffee", 450, 2), item("bagel", 300, 1)] }
#[test] fn total_of_sample_cart() { assert_eq!(cart_total(&sample_cart()), 1200); }
#[test] fn empty_cart_costs_nothing() { assert_eq!(cart_total(&[]), 0); }
#[test] #[should_panic(expected = "at most 100%")] fn discount_over_100_panics() { apply_discount(1000, 150); }
// Ein Test kann ein Result zurückgeben, dann kannst du darin `?` benutzen. #[test] fn parses_a_price() -> Result<(), String> { let cents = parse_price("12.50")?; assert_eq!(cents, 1250); Ok(()) }
#[test] #[ignore = "slow: checks ten million prices"] fn every_price_round_trips() { for cents in 0..10_000_000 { assert_eq!(parse_price(&format_price(cents)), Ok(cents)); } }}Ein paar Dinge fallen hier auf:
- Helper und Fixtures sind ganz normale Funktionen. Rust hat kein spezielles Fixture-System, und du wirst es selten vermissen. Weil sie in
#[cfg(test)]stehen, landen sie nie in deinem echten Programm. #[should_panic(expected = "...")]besteht nur, wenn die Panic-Meldung diesen Text enthält. Ein Panic durch einen anderen Bug zählt also nicht. Testen in Rust erklärt das mit weiteren Beispielen.- Tests, die ein
Resultzurückgeben, erlauben dir?. Gibt der TestErrzurück, schlägt er fehl und zeigt den Fehler an, zum BeispielError: "missing '.' in \"12,50\"". #[ignore]überspringt einen langsamen oder besonderen Test standardmäßig. Der Text nach=ist der Grund, undcargo testgibt ihn aus.
Die Meldungen und Testnamen bleiben auf Englisch, damit sie zur echten Ausgabe von cargo test weiter unten passen.
Der Integrationstest benutzt die Bibliothek über ihre öffentliche API. Sein gemeinsamer Helper liegt in tests/common/mod.rs:
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> { vec![ Item { name: "coffee".into(), price_cents: 450, qty: 2 }, Item { name: "bagel".into(), price_cents: 300, qty: 1 }, ]}mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]fn discounted_breakfast_total() { let total = cart_total(&common::breakfast_cart()); let to_pay = apply_discount(total, 10); println!("to pay: {}", format_price(to_pay)); assert_eq!(format_price(to_pay), "10.80");}Wenn du cargo test ausführst, siehst du einen Abschnitt pro Testart:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 teststest tests::every_price_round_trips ... ignored, slow: checks ten million pricestest tests::parses_a_price ... oktest tests::empty_cart_costs_nothing ... oktest tests::total_of_sample_cart ... oktest tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 testtest discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 testtest src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sAuswählen, welche Tests laufen
Abschnitt betitelt „Auswählen, welche Tests laufen“Im Alltag willst du nicht immer alle Tests ausführen. Diese Befehle helfen dir dabei. Alles nach einem einzelnen -- geht an das Testprogramm selbst, nicht an Cargo.
cargo test discount # nur Tests, deren Name "discount" enthältcargo test --test checkout # nur die Datei tests/checkout.rscargo test --lib # nur die Unit-Tests in src/cargo test --doc # nur die Doc-Testscargo test -- --ignored # nur die Tests mit #[ignore]cargo test -- --include-ignored # alles, auch die ignoriertencargo test -- --nocapture # println!-Ausgabe auch bei bestandenen Tests zeigencargo test -- --test-threads=1 # Tests nacheinander ausführenDer Filter sucht nach einem Teil des vollständigen Testnamens, und zwar in allen Testdateien. Hier findet discount einen Unit-Test und einen Integrationstest:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 testtest tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 testtest discounted_breakfast_total ... okDer langsame Test läuft nur, wenn du ihn ausdrücklich anforderst. Zehn Millionen Hin-und-zurück-Umwandlungen haben hier etwa 3 Sekunden gedauert:
running 1 testtest tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20sStandardmäßig versteckt Rust die println!-Ausgabe von bestandenen Tests, damit der Bericht übersichtlich bleibt. Mit --nocapture siehst du sie:
$ cargo test --test checkout -- --nocapturerunning 1 testto pay: 10.80test discounted_breakfast_total ... okTest-Doubles: mit einem Trait vortäuschen
Abschnitt betitelt „Test-Doubles: mit einem Trait vortäuschen“Mancher Code redet mit der Außenwelt: Er verschickt E-Mails, ruft eine API auf oder liest die Uhr. Deine Tests sollen aber keine echten E-Mails verschicken. Und du kannst auch nicht testen, „was um 21 Uhr passiert“, wenn der Code immer die echte Uhr fragt.
Die idiomatische Antwort in Rust braucht gar keine Bibliothek. Beschreibe die Abhängigkeit als Trait, lass deine Funktion alles annehmen, was diesen Trait implementiert, und schreib für die Tests einen kleinen Fake (eine Attrappe). Das echte Programm übergibt das echte Teil. Die Tests übergeben den Fake.
Hier schickt eine Bestellbestätigung eine E-Mail an den Kunden, und bei großen Bestellungen wird zusätzlich das Vertriebsteam benachrichtigt:
trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Der echte Mailer. In einer echten App würde er einen E-Mail-Dienst aufrufen.struct ConsoleMailer;
impl Mailer for ConsoleMailer { fn send(&mut self, to: &str, body: &str) { println!("E-Mail an {to}: {body}"); }}
struct Order { id: u32, email: String, total_cents: u32,}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) { let body = format!("Bestellung #{} bestätigt", order.id); mailer.send(&order.email, &body); if order.total_cents >= 10_000 { }}
fn main() { let mut mailer = ConsoleMailer; confirm_order(&small, &mut mailer); confirm_order(&big, &mut mailer);}
#[cfg(test)]mod tests { use super::*;
// Ein Fake: Er verschickt nichts, sondern schreibt nur jeden Aufruf mit. #[derive(Default)] struct FakeMailer { sent: Vec<(String, String)>, }
impl Mailer for FakeMailer { fn send(&mut self, to: &str, body: &str) { self.sent.push((to.to_string(), body.to_string())); } }
#[test] fn small_order_sends_one_email() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1); }
#[test] fn big_order_also_tells_sales() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2); }}Ausgabe:
E-Mail an [email protected]: Bestellung #7 bestätigtE-Mail an [email protected]: Bestellung #8 bestätigtE-Mail an [email protected]: Große Bestellung #8Dieses Beispiel braucht keine externen Crates. Deshalb kannst du im Playground im Menü neben Run auch Test auswählen:
running 2 teststest tests::small_order_sends_one_email ... oktest tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sconfirm_order weiß nicht, welchen Mailer es bekommt – und es ist ihm auch egal. Das ist schon der ganze Trick. Dieselbe Idee funktioniert für einen Clock-Trait (die Tests benutzen eine Uhr, die auf 21 Uhr stehen geblieben ist), ein PaymentGateway oder einen Storage-Trait, der in Tests mit einer HashMap arbeitet. Die Uhr-Variante übst du weiter unten selbst.
Mocks mit mockall
Abschnitt betitelt „Mocks mit mockall“Fakes von Hand zu schreiben ist für ein oder zwei Traits völlig in Ordnung. Wenn es viele werden, kann das Crate mockall (auf Englisch) sie für dich erzeugen. Füge es als Dev-Dependency (Entwicklungsabhängigkeit) hinzu, damit es nur für Tests und Beispiele benutzt wird (Cargo und Crates erklärt die Abschnitte von Cargo.toml):
cargo add --dev mockallSchreib #[automock] über den Trait, und mockall erzeugt einen Typ MockMailer. Der Teil cfg_attr(test, ...) bedeutet „mach das nur beim Testen“. So landet der Mock nie in deinem echten Build. Cargo-Features und #[cfg] erklärt cfg genauer.
#[cfg(test)]use mockall::automock;
#[cfg_attr(test, automock)]pub trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Order und confirm_order sind dieselben wie oben.
#[cfg(test)]mod tests { use super::*;
#[test] fn big_order_also_tells_sales() { let mut mailer = MockMailer::new(); mailer .expect_send() .times(1) .return_const(()); mailer .expect_send() .times(1) .return_const(());
confirm_order(&order, &mut mailer); }}Jedes expect_send() ist eine Expectation (Erwartung): „Ich erwarte, dass send genau einmal mit Argumenten aufgerufen wird, die zu withf passen.“ return_const(()) legt fest, was der Mock zurückgibt (hier: nichts). Wenn der Mock am Ende des Tests freigegeben wird, prüft mockall, ob alle Erwartungen erfüllt wurden.
Um zu sehen, wie er einen Bug erwischt, ändere die Regel in confirm_order zu order.total_cents > 12_000. Jetzt wird die E-Mail an den Vertrieb nie verschickt:
thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1Diese Meldung schreibt mockall auf Englisch: Die Erwartung wurde 0-mal erfüllt, also seltener als die erwarteten 1-mal.
Fake oder Mock? Fang mit einem handgeschriebenen Fake an. Das ist ganz normales Rust, leicht zu lesen und ohne zusätzliche Abhängigkeit. Greif zu mockall, wenn du viele Traits vortäuschen musst oder wenn es dir wirklich darauf ankommt, dass etwas „genau N-mal mit diesen Argumenten aufgerufen“ wird.
Property-Tests mit proptest
Abschnitt betitelt „Property-Tests mit proptest“Ein normaler Test prüft ein Beispiel, das du ausgesucht hast: „12.50 wird zu 1250“. Ein Property-Test (eigenschaftsbasierter Test) beschreibt eine Regel, die für jede Eingabe gelten soll. Dann probiert eine Bibliothek Hunderte zufällige Eingaben aus, um die Regel zu brechen. Das ist, als würdest du eine sehr geduldige Person einstellen, die jeden seltsamen Wert ausprobiert, an den du nicht gedacht hast.
Gute Eigenschaften sehen oft so aus:
- Zweimal dasselbe tun macht es rückgängig: Eine Liste zweimal umzudrehen ergibt wieder die ursprüngliche Liste.
- Hin und zurück ergibt das, womit du angefangen hast:
parse(format(x)) == x. - Eine Tatsache gilt immer: Eine sortierte Liste ist genauso lang wie die Eingabe.
Füge proptest (auf Englisch) mit cargo add --dev proptest hinzu und schreib die Tests in das Makro proptest!. Jede Zeile der Form name in strategie legt fest, welche Art von Zufallswerten erzeugt wird:
use proptest::prelude::*;use shop::{format_price, parse_price};
proptest! { #[test] fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) { let mut copy = list.clone(); copy.reverse(); copy.reverse(); prop_assert_eq!(copy, list); }
#[test] fn price_survives_format_then_parse(cents in 0u32..10_000_000) { prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents)); }}any::<i32>() heißt „irgendein i32“, 0u32..10_000_000 heißt „eine Zahl in diesem Bereich“, und vec(..., 0..100) baut Listen mit bis zu 99 Elementen. Standardmäßig wird jede Eigenschaft mit 256 zufälligen Fällen geprüft. Benutze in proptest! prop_assert_eq! statt assert_eq!, damit proptest den Fehlschlag sauber behandeln kann. Beide Tests bestehen:
running 2 teststest price_survives_format_then_parse ... oktest reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01sDas ist übrigens dieselbe Prüfung wie der langsame #[ignore]-Test von vorhin. Der hat alle zehn Millionen Preise in 3 Sekunden geprüft. Der Property-Test probiert 256 zufällige in einer Hundertstelsekunde – deshalb kann er bei jedem Lauf mitlaufen.
Jetzt bauen wir absichtlich einen Fehler ein. Jemand „verbessert“ format_price, damit es ein Tausendertrennzeichen ausgibt, etwa 1,000.00. Sieht schöner aus, aber parse_price wurde dabei vergessen:
pub fn format_price(cents: u32) -> String { let euros = cents / 100; let euros = if euros >= 1000 { format!("{},{:03}", euros / 1000, euros % 1000) } else { euros.to_string() }; format!("{euros}.{:02}", cents % 100)}Hier ist die echte Ausgabe von cargo test --test properties, leicht gekürzt:
---- price_survives_format_then_parse stdout ----proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:Test failed: assertion failed: `(left == right)` left: `Err("bad euros in \"1,000.00\"")`, right: `Ok(100000)` at tests/properties.rs:15.minimal failing input: cents = 100000Schau dir minimal failing input: cents = 100000 an („kleinste Eingabe, die fehlschlägt“). Der erste Zufallswert, der fehlschlug, war wahrscheinlich etwas Krummes wie 7384912. Dann schrumpft proptest ihn (Shrinking): Es probiert immer kleinere Werte, die immer noch fehlschlagen, und meldet den einfachsten. 100000 Cent ergeben genau 1,000.00 – den ersten Preis, der ein Komma bekommt. Das führt dich direkt zum Bug.
Proptest speichert den fehlgeschlagenen Fall außerdem in einer Datei .proptest-regressions neben dem Test und probiert ihn bei jedem späteren Lauf zuerst aus. Committe diese Datei, damit der Bug nie unbemerkt zurückkommen kann. Wenn dir eine kleinere Bibliothek lieber ist: quickcheck (auf Englisch) erledigt dieselbe Aufgabe mit weniger Funktionen.
Snapshot-Tests mit insta
Abschnitt betitelt „Snapshot-Tests mit insta“Manche Ausgaben sind lang: ein Kassenbon, ein Fehlerbericht, ein großes JSON-Dokument. Ein assert_eq! mit dem ganzen erwarteten Text zu schreiben ist mühsam. Ein Snapshot-Test speichert die Ausgabe beim ersten Mal in einer Datei. Bei späteren Läufen vergleicht er die neue Ausgabe mit diesem gespeicherten „Foto“.
Mit insta (auf Englisch; cargo add --dev insta) ist der Test nur eine Zeile:
pub fn receipt(items: &[Item]) -> String { let mut out = String::new(); for i in items { let line = format_price(i.price_cents * i.qty); out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line)); } out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items)))); out}
#[test]fn receipt_layout() { insta::assert_snapshot!(receipt(&sample_cart()));}Der erste Lauf schlägt absichtlich fehl und schreibt eine Datei .snap.new, die du prüfen sollst. Wenn du sie akzeptierst (mit cargo insta review aus dem Werkzeug cargo-insta oder indem du die Datei in .snap umbenennst), liegt sie in src/snapshots/:
---source: src/lib.rsassertion_line: 84expression: receipt(&sample_cart())---coffee x2 9.00bagel x1 3.00TOTAL 12.00Die Texte im Kassenbon bleiben auf Englisch, damit sie zur echten Snapshot-Datei und zum Diff weiter unten passen.
Ändert später jemand "TOTAL" in "Total", schlägt der Test mit einem übersichtlichen Diff fehl:
-old snapshot+new results────────────┬─────────────────────────────────────────────────────────────────── 1 1 │ coffee x2 9.00 2 2 │ bagel x1 3.00 3 │-TOTAL 12.00 3 │+Total 12.00────────────┴───────────────────────────────────────────────────────────────────To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`War die Änderung gewollt, akzeptierst du den neuen Snapshot. Wenn nicht, hast du gerade einen Bug gefunden. Snapshots sind großartig für Prüfungen nach dem Motto „das soll sich nicht aus Versehen ändern“. Sie sagen dir aber nicht, ob die Ausgabe richtig ist. Lies den ersten Snapshot also gründlich, bevor du ihn akzeptierst.
Benchmarks mit criterion
Abschnitt betitelt „Benchmarks mit criterion“Wenn du wissen willst, ob Code schnell ist, miss es. Bauchgefühl liegt erstaunlich oft daneben. Die Zeit in einem #[test] zu messen, funktioniert auch nicht gut: Tests werden ohne Optimierungen gebaut, und ein einzelner Lauf ist zu ungenau.
criterion (auf Englisch) ist das übliche Werkzeug für stabiles Rust. Es führt deinen Code viele Male aus, übernimmt die Statistik für dich und merkt sich das letzte Ergebnis. So kann es dir sagen, ob du schneller oder langsamer geworden bist. Füge es mit cargo add --dev criterion hinzu, melde Cargo die Benchmark-Datei an und schalte das eingebaute Test-Harness ab:
[[bench]]name = "stock"harness = falseDieser Benchmark fragt: Um zu prüfen, ob ein Produktcode auf Lager ist – ist bei 1.000 Codes ein Vec oder ein HashSet schneller?
use std::collections::HashSet;use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) { // 1.000 Produktcodes wie "SKU-0", "SKU-1", ... let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect(); let code_set: HashSet<String> = codes.iter().cloned().collect(); let wanted = String::from("SKU-999"); // der schlimmste Fall für einen Vec: der letzte
c.bench_function("vec contains", |b| { b.iter(|| codes.contains(black_box(&wanted))) }); c.bench_function("hashset contains", |b| { b.iter(|| code_set.contains(black_box(&wanted))) });}
criterion_group!(benches, stock_lookup);criterion_main!(benches);b.iter(...) ist der Code, der gemessen wird. std::hint::black_box versteckt einen Wert vor dem Optimierer. Ohne das könnte der Compiler bemerken, dass sich das Ergebnis nie ändert, es nur einmal ausrechnen – und du würdest gar nichts messen.
Starte den Benchmark mit cargo bench. Das baut immer im optimierten Modus (Release). Auf deinem Rechner bekommst du andere Zahlen:
vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) low mild 1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]Found 8 outliers among 100 measurements (8.00%) 4 (4.00%) high mild 4 (4.00%) high severeSo liest du das: Die drei Zeiten bilden einen Bereich. Die mittlere ist die beste Schätzung, und die beiden äußeren sagen: „Der echte Wert liegt sehr wahrscheinlich dazwischen.“ Hier braucht das HashSet etwa 19 Nanosekunden und der Vec etwa 2,4 Mikrosekunden. Das Set ist in diesem Fall also über 100-mal schneller. „Outliers“ (Ausreißer) sind ein paar ungewöhnlich langsame oder schnelle Läufe, oft verursacht durch andere Programme auf deinem Rechner. Ein paar davon sind normal.
Führ den Benchmark nach einer Änderung noch einmal aus, und criterion vergleicht mit dem letzten Lauf:
vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs] change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05) No change in performance detected.Die Änderung liegt unter 1 % und könnte reines Rauschen sein, deshalb meldet criterion „No change“ (keine Änderung). Außerdem schreibt es einen HTML-Bericht nach target/criterion/. Wann du dich um Geschwindigkeit kümmern solltest (und wann nicht), erfährst du unter Performance in Rust.
In älterem Code siehst du vielleicht noch #[bench]-Funktionen. Sie benutzen den eingebauten test::Bencher, der nur mit dem Nightly-Compiler funktioniert:
#![feature(test)] // nur mit Nightlyextern crate test;
#[bench]fn bench_total(b: &mut test::Bencher) { b.iter(|| (1..=1000u64).sum::<u64>());}Mit stabilem Rust nimmst du criterion.
Testabdeckung und CI
Abschnitt betitelt „Testabdeckung und CI“Sobald du Tests hast, helfen dir zwei weitere Werkzeuge. Du installierst sie mit cargo install, wie die anderen Werkzeuge auf Rust-Werkzeuge und Einrichtung.
cargo-nextest ist ein schnellerer Test-Runner. Er führt jeden Test in einem eigenen Prozess aus, zeigt eine übersichtlichere Ausgabe und kann wackelige („flaky“) Tests wiederholen. Eine Sache solltest du wissen: Doc-Tests führt er nicht aus. Starte also zusätzlich cargo test --doc.
cargo install cargo-nextest --lockedcargo nextest runDie Testabdeckung (Coverage) zeigt dir, welche Zeilen deine Tests wirklich ausgeführt haben. cargo-llvm-cov nutzt die Coverage-Unterstützung, die im Compiler eingebaut ist, und läuft unter Linux, macOS und Windows:
cargo install cargo-llvm-covcargo llvm-cov # Übersichtstabelle im Terminalcargo llvm-cov --html # ein Bericht zum Durchklicken in target/llvm-cov/htmlcargo-tarpaulin ist eine ältere, beliebte Alternative. Es funktioniert am besten unter Linux und wird oft in CI eingesetzt:
cargo install cargo-tarpaulincargo tarpaulin --out HtmlBetrachte die Testabdeckung als Landkarte, nicht als Schulnote. Sie zeigt dir Code, den kein Test je berührt. Sie kann dir aber nicht sagen, ob deine Asserts das Richtige prüfen. 80 % Abdeckung mit guten Asserts ist besser als 100 % mit schwachen.
In der CI (GitHub Actions, GitLab und so weiter) sind dieselben drei Befehle, die du lokal ausführst, ein guter Anfang. So bedeutet ein fehlgeschlagener Check überall dasselbe:
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo testHäufige Fehler
Abschnitt betitelt „Häufige Fehler“Integrationstests für ein reines Binary-Crate. Wenn dein Projekt nur src/main.rs hat, können Dateien in tests/ nichts daraus importieren:
error[E0432]: unresolved import `todo` --> tests/tasks.rs:1:5 |1 | use todo::add_task; | ^^^^ use of unresolved module or unlinked crate `todo`Der Compiler sagt hier, dass er das Modul bzw. Crate todo nicht findet. Lösung: Verschieb die Logik nach src/lib.rs und halte main.rs klein. Dann ruft main.rs die Bibliothek auf – und deine Tests ebenfalls. Nebenbei lässt sich der Code so auch leichter wiederverwenden.
Gemeinsame Helper in tests/common.rs. Jede .rs-Datei direkt in tests/ gilt als eigene Testdatei. Dadurch bekommst du einen nutzlosen zusätzlichen Abschnitt in der Ausgabe:
Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 testsLösung: Nimm tests/common/mod.rs und schreib mod common; in jede Testdatei, die es braucht.
Test-Crates unter [dependencies]. proptest, mockall, insta und criterion gehören unter [dev-dependencies] (benutze cargo add --dev). Sonst muss jeder Nutzer deines Crates sie herunterladen und kompilieren.
Tests, die sich Zustand teilen. Tests laufen parallel. Wenn zwei Tests dieselbe Datei schreiben oder dieselbe Umgebungsvariable ändern, schlagen sie zufällig fehl. Gib jedem Test seinen eigenen temporären Ordner (das Crate tempfile hilft dabei) oder führ die Tests im Notfall mit --test-threads=1 aus.
Benchmarks ohne black_box oder in einem Test. Der Optimierer kann Arbeit wegwerfen, deren Ergebnis niemand benutzt. Und eine Zeitmessung in einem #[test] im Debug-Modus sagt dir wenig. Nimm criterion mit black_box.
Übungen
Abschnitt betitelt „Übungen“1. Eine Begrüßung, die sich testen lässt
Abschnitt betitelt „1. Eine Begrüßung, die sich testen lässt“Diese Funktion lässt sich nicht zuverlässig testen, weil sie immer die echte Uhr fragt:
fn greeting() -> &'static str { let hour = current_hour(); // liest die Systemzeit match hour { 5..=11 => "Guten Morgen", 12..=17 => "Guten Tag", _ => "Guten Abend", }}Schreib sie so um, dass sie einen Clock-Trait mit einer Methode fn hour(&self) -> u64 bekommt. Schreib eine FixedClock, die immer dieselbe Stunde zurückgibt, und teste die Grenzen: 11 und 12, 17 und 18. Tipp: struct FixedClock(u64); reicht völlig. Die echte App würde eine SystemClock übergeben, die die Uhrzeit liest.
Lösung anzeigen
trait Clock { fn hour(&self) -> u64; // 0 bis 23}
// Eine Uhr, die immer dieselbe Stunde anzeigt. Perfekt für Tests.struct FixedClock(u64);
impl Clock for FixedClock { fn hour(&self) -> u64 { self.0 }}
fn greeting(clock: &impl Clock) -> &'static str { match clock.hour() { 5..=11 => "Guten Morgen", 12..=17 => "Guten Tag", _ => "Guten Abend", }}
fn main() { for hour in [8, 12, 21] { println!("{hour}:00 -> {}", greeting(&FixedClock(hour))); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn morning_ends_at_noon() { assert_eq!(greeting(&FixedClock(11)), "Guten Morgen"); assert_eq!(greeting(&FixedClock(12)), "Guten Tag"); }
#[test] fn evening_starts_at_six() { assert_eq!(greeting(&FixedClock(17)), "Guten Tag"); assert_eq!(greeting(&FixedClock(18)), "Guten Abend"); }
#[test] fn very_early_is_still_evening() { assert_eq!(greeting(&FixedClock(3)), "Guten Abend"); }}Ausgabe:
8:00 -> Guten Morgen12:00 -> Guten Tag21:00 -> Guten Abendgreeting entscheidet nicht mehr selbst, woher die Uhrzeit kommt – das macht jetzt der Aufrufer. Diese eine Änderung macht jede Stunde in Millisekunden testbar. Wähl im Playground-Menü Test, und alle 3 Tests bestehen.
2. Finde die Regel und lass proptest sie prüfen
Abschnitt betitelt „2. Finde die Regel und lass proptest sie prüfen“Hier ist eine Funktion, die ein Blog-Tag wie " Web Dev " in "web-dev" umwandelt:
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}Schreib zwei Property-Tests mit proptest: Ein Tag, das schon normalisiert ist, ändert sich beim erneuten Normalisieren nicht. Und das Ergebnis enthält nie Leerraum (Whitespace). Tipp: Die Strategie ".*" erzeugt zufällige Zeichenketten (sie ist ein regulärer Ausdruck). Für „kein Leerraum“ funktioniert prop_assert!(!s.contains(char::is_whitespace)).
Lösung anzeigen
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}
fn main() { for tag in ["Rust", " Web Dev ", "GAME dev"] { println!("{tag:?} -> {:?}", normalize_tag(tag)); }}
#[cfg(test)]mod tests { use super::*; use proptest::prelude::*;
proptest! { #[test] fn normalizing_twice_changes_nothing(tag in ".*") { let once = normalize_tag(&tag); prop_assert_eq!(normalize_tag(&once), once); }
#[test] fn result_has_no_whitespace(tag in ".*") { let normalized = normalize_tag(&tag); prop_assert!(!normalized.contains(char::is_whitespace)); } }}Ausgabe:
"Rust" -> "rust"" Web Dev " -> "web-dev""GAME dev" -> "game-dev"Run funktioniert im Playground, Test aber nicht, weil es dort kein proptest gibt. Füge es in deinem eigenen Projekt mit cargo add --dev proptest hinzu und führ cargo test aus:
running 2 teststest tests::result_has_no_whitespace ... oktest tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02sDie zufälligen Zeichenketten enthalten Tabs, Zeilenumbrüche und Buchstaben aus anderen Sprachen – Dinge, die du in einem handgeschriebenen Test wahrscheinlich nie eintippen würdest. „Zweimal ausführen ist dasselbe wie einmal“ ist eine Eigenschaft, die sich bei jeder Aufräumfunktion zu prüfen lohnt.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Unit-Tests gehören in
#[cfg(test)] mod testsdirekt neben den Code, Integrationstests intests/(gemeinsame Helper intests/common/mod.rs) und Doc-Tests in///-Kommentare. - Markiere langsame Tests mit
#[ignore]und starte sie mitcargo test -- --ignored. Filtere mitcargo test irgendein_namenach Namen, und zeig dieprintln!-Ausgabe mit-- --nocapturean. - Um Code zu testen, der mit der Außenwelt redet, versteck diese Abhängigkeit hinter einem Trait und übergib in den Tests einen Fake. Nimm mockall, wenn handgeschriebene Fakes zu mühsam werden.
- Property-Tests (proptest) prüfen eine Regel mit Hunderten zufälligen Eingaben und schrumpfen einen Fehlschlag auf das einfachste Beispiel. Snapshot-Tests (insta) erkennen versehentliche Änderungen in großen Ausgaben.
- Miss die Geschwindigkeit mit criterion und
cargo bench, und pack Eingaben instd::hint::black_box.#[bench]braucht Nightly. - cargo-nextest führt Tests schneller aus; cargo-llvm-cov und cargo-tarpaulin zeigen, welche Zeilen deine Tests abdecken.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Mehrere Dateien in tests/ brauchen dieselbe Hilfsfunktion. Wo sollte sie hin?
2. Was macht cargo test -- --ignored?
3. Eine proptest-Eigenschaft schlägt fehl. Was meldet proptest als „minimal failing input“?
4. Warum packen criterion-Benchmarks Werte in std::hint::black_box?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Automatisierte Tests schreiben – Das Rust-Buch: das offizielle Kapitel darüber, wie du Unit-Tests und Integrationstests schreibst, ausführst und organisierst.
- Testing – Rust by Example (auf Englisch): kurze Beispiele für Unit-Tests, Doc-Tests, Integrationstests und Dev-Dependencies.
- The proptest book (auf Englisch): Strategien, Shrinking, Konfiguration und wie sich proptest von quickcheck unterscheidet.
- Dokumentation von mockall (auf Englisch):
#[automock], Expectations, Argumente abgleichen, Rückgabewerte und Mocks für Structs. - Criterion.rs-Benutzerhandbuch (auf Englisch): erste Schritte, die Ausgabe lesen, Funktionen vergleichen und die HTML-Berichte.