Zum Inhalt springen

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.

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 Benchmark

Rust 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 nur pub-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 Result zurückgeben, erlauben dir ?. Gibt der Test Err zurück, schlägt er fehl und zeigt den Fehler an, zum Beispiel Error: "missing '.' in \"12,50\"".
  • #[ignore] überspringt einen langsamen oder besonderen Test standardmäßig. Der Text nach = ist der Grund, und cargo test gibt 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:

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 },
]
}
tests/checkout.rs
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 tests
test tests::every_price_round_trips ... ignored, slow: checks ten million prices
test tests::parses_a_price ... ok
test tests::empty_cart_costs_nothing ... ok
test tests::total_of_sample_cart ... ok
test 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 test
test 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 test
test 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.00s

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.

Terminal window
cargo test discount # nur Tests, deren Name "discount" enthält
cargo test --test checkout # nur die Datei tests/checkout.rs
cargo test --lib # nur die Unit-Tests in src/
cargo test --doc # nur die Doc-Tests
cargo test -- --ignored # nur die Tests mit #[ignore]
cargo test -- --include-ignored # alles, auch die ignorierten
cargo test -- --nocapture # println!-Ausgabe auch bei bestandenen Tests zeigen
cargo test -- --test-threads=1 # Tests nacheinander ausführen

Der 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 test
test 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 test
test discounted_breakfast_total ... ok

Der 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 test
test tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20s

Standardmäßig versteckt Rust die println!-Ausgabe von bestandenen Tests, damit der Bericht übersichtlich bleibt. Mit --nocapture siehst du sie:

$ cargo test --test checkout -- --nocapture
running 1 test
to pay: 10.80
test discounted_breakfast_total ... ok

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 {
mailer.send("[email protected]", &format!("Große Bestellung #{}", order.id));
}
}
fn main() {
let mut mailer = ConsoleMailer;
let small = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
let big = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
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();
let order = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, "[email protected]");
}
#[test]
fn big_order_also_tells_sales() {
let mut fake = FakeMailer::default();
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2);
assert_eq!(fake.sent[1], ("[email protected]".to_string(), "Große Bestellung #8".to_string()));
}
}

Ausgabe:

E-Mail an [email protected]: Bestellung #7 bestätigt
E-Mail an [email protected]: Bestellung #8 bestätigt
E-Mail an [email protected]: Große Bestellung #8
▶ Selbst ausprobieren

Dieses Beispiel braucht keine externen Crates. Deshalb kannst du im Playground im Menü neben Run auch Test auswählen:

running 2 tests
test tests::small_order_sends_one_email ... ok
test tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

confirm_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.

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):

Terminal window
cargo add --dev mockall

Schreib #[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.

src/orders.rs
#[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()
.withf(|to, _body| to == "[email protected]")
.times(1)
.return_const(());
mailer
.expect_send()
.withf(|to, body| to == "[email protected]" && body == "Große Bestellung #8")
.times(1)
.return_const(());
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
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 1

Diese 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.

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:

tests/properties.rs
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 tests
test price_survives_format_then_parse ... ok
test reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Das 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 = 100000

Schau 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.

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.rs
assertion_line: 84
expression: receipt(&sample_cart())
---
coffee x2 9.00
bagel x1 3.00
TOTAL 12.00

Die 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.

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 = false

Dieser Benchmark fragt: Um zu prüfen, ob ein Produktcode auf Lager ist – ist bei 1.000 Codes ein Vec oder ein HashSet schneller?

benches/stock.rs
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 severe

So 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 Nightly
extern crate test;
#[bench]
fn bench_total(b: &mut test::Bencher) {
b.iter(|| (1..=1000u64).sum::<u64>());
}

Mit stabilem Rust nimmst du criterion.

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.

Terminal window
cargo install cargo-nextest --locked
cargo nextest run

Die 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:

Terminal window
cargo install cargo-llvm-cov
cargo llvm-cov # Übersichtstabelle im Terminal
cargo llvm-cov --html # ein Bericht zum Durchklicken in target/llvm-cov/html

cargo-tarpaulin ist eine ältere, beliebte Alternative. Es funktioniert am besten unter Linux und wird oft in CI eingesetzt:

Terminal window
cargo install cargo-tarpaulin
cargo tarpaulin --out Html

Betrachte 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:

Terminal window
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test

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 tests

Lö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.

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 Morgen
12:00 -> Guten Tag
21:00 -> Guten Abend
▶ Selbst ausprobieren

greeting 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.

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"
▶ Selbst ausprobieren

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 tests
test tests::result_has_no_whitespace ... ok
test tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

Die 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.

  • Unit-Tests gehören in #[cfg(test)] mod tests direkt neben den Code, Integrationstests in tests/ (gemeinsame Helper in tests/common/mod.rs) und Doc-Tests in ///-Kommentare.
  • Markiere langsame Tests mit #[ignore] und starte sie mit cargo test -- --ignored. Filtere mit cargo test irgendein_name nach Namen, und zeig die println!-Ausgabe mit -- --nocapture an.
  • 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 in std::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

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