Salta ai contenuti

Test avanzati in Rust: mock, property test e benchmark

Sai già scrivere un test con #[test] e assert_eq! (altrimenti comincia da Test). Per i programmi piccoli basta. Ma i progetti veri sollevano presto nuove domande. Dove metto i test? Come testo il codice che manda email o legge l’orologio? Come trovo quell’unico input a cui non avevo pensato? E la mia versione «più veloce» lo è davvero?

Pensa a un’officina per auto. I test di base controllano che ogni pezzo funzioni. Questa pagina aggiunge il resto dell’officina: un banco di prova per i pezzi (fake e mock), una macchina che scuote l’auto con migliaia di buche casuali (property test, test basati sulle proprietà), una foto di com’era tutto l’ultima volta (snapshot) e un cronometro di cui fidarti (benchmark).

Una nota sincera prima di cominciare: il pulsante Run del Rust Playground esegue main, non i test, e non ha i crate di test usati più avanti. Per questo la maggior parte degli esempi mostra l’output reale di cargo test e cargo bench, preso da un piccolo progetto su un normale computer. Quando un programma ha un main, trovi come sempre il pulsante per provarlo.

Ecco la struttura della piccola libreria shop usata in questa pagina:

shop/
├── Cargo.toml
├── src/
│ ├── lib.rs <- codice + test unitari in `mod tests`
│ └── orders.rs
├── tests/
│ ├── checkout.rs <- un test di integrazione
│ └── common/
│ └── mod.rs <- funzioni di supporto condivise dai test di integrazione
└── benches/
└── stock.rs <- un benchmark

Rust ha tre tipi di test, e cargo test li esegue tutti:

  • I test unitari stanno accanto al codice, in un blocco #[cfg(test)] mod tests. Possono vedere anche le funzioni private.
  • I test di integrazione stanno nella cartella tests/. Ogni file viene compilato come un crate separato che usa la tua libreria dall’esterno, esattamente come farebbe un utente. Quindi possono chiamare solo gli elementi pub.
  • I doc test sono gli esempi di codice dentro i commenti ///. Dimostrano che la tua documentazione funziona ancora. La pagina Documentazione li tratta nel dettaglio.

Ecco una parte di src/lib.rs. Nota il doc test su apply_discount, e le funzioni di supporto e le fixture dentro mod tests:

/// Una riga di un carrello della spesa. I prezzi sono in centesimi.
#[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()
}
/// Toglie `percent` per cento da un prezzo.
///
/// ```
/// 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
}
/// Trasforma "12.50" in 1250 centesimi.
pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Trasforma 1250 centesimi in "12.50".
pub fn format_price(cents: u32) -> String {
format!("{}.{:02}", cents / 100, cents % 100)
}
#[cfg(test)]
mod tests {
use super::*;
// Una funzione di supporto: breve da chiamare, e ogni test crea gli articoli allo stesso modo.
fn item(name: &str, price_cents: u32, qty: u32) -> Item {
Item { name: name.to_string(), price_cents, qty }
}
// Una fixture: un carrello realistico condiviso da più test.
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);
}
// Un test può restituire un Result, così puoi usare `?` al suo interno.
#[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));
}
}
}

Alcune cose da notare:

  • Funzioni di supporto e fixture sono solo funzioni. Rust non ha un sistema speciale per le fixture, e raramente ti mancherà. Siccome stanno dentro #[cfg(test)], non finiscono mai nel tuo programma vero.
  • #[should_panic(expected = "...")] passa solo se il messaggio del panic contiene quel testo, quindi un panic dovuto a un altro bug non conta. Test lo spiega con altri esempi.
  • I test che restituiscono Result ti permettono di usare ?. Se il test restituisce Err, fallisce e stampa l’errore, per esempio Error: "missing '.' in \"12,50\"".
  • #[ignore] salta di default un test lento o speciale. Il testo dopo = è il motivo, e cargo test lo stampa. Qui è rimasto in inglese, perché compare tale e quale nell’output reale più sotto («lento: controlla dieci milioni di prezzi»).

Il test di integrazione usa la libreria tramite la sua API pubblica. La sua funzione di supporto condivisa sta 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");
}

Eseguendo cargo test vedi una sezione per ogni tipo di test:

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

Nel lavoro di tutti i giorni non vuoi sempre eseguire ogni test. Questi comandi ti aiutano. Tutto quello che viene dopo un -- isolato va al programma di test, non a Cargo.

Terminal window
cargo test discount # solo i test il cui nome contiene "discount"
cargo test --test checkout # solo il file tests/checkout.rs
cargo test --lib # solo i test unitari in src/
cargo test --doc # solo i doc test
cargo test -- --ignored # solo i test con #[ignore]
cargo test -- --include-ignored # tutto, compresi quelli ignorati
cargo test -- --nocapture # mostra l'output di println! dei test che passano
cargo test -- --test-threads=1 # esegue i test uno alla volta

Il filtro cerca una parte del nome completo del test, in tutti i file di test. Qui discount trova un test unitario e un test di integrazione:

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

Il test lento viene eseguito solo quando lo chiedi tu. Dieci milioni di andata e ritorno hanno richiesto circa 3 secondi:

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

Di default Rust nasconde l’output di println! dei test che passano, così il resoconto resta pulito. --nocapture lo mostra:

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

Una parte del codice parla con il mondo esterno: manda email, chiama un’API o legge l’orologio. Non vuoi che i tuoi test mandino email vere. E non puoi nemmeno testare «che cosa succede alle 21» se il codice chiede sempre l’ora all’orologio vero.

La risposta idiomatica di Rust non ha bisogno di nessuna libreria. Descrivi la dipendenza con un trait, fai in modo che la funzione accetti qualsiasi cosa lo implementi, e scrivi un piccolo fake (finto) per i test. Il programma vero passa l’oggetto vero. I test passano il fake.

Qui la conferma di un ordine manda un’email al cliente, e gli ordini grandi avvisano anche l’ufficio vendite:

trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Il mailer vero. In un'app reale chiamerebbe un servizio di email.
struct ConsoleMailer;
impl Mailer for ConsoleMailer {
fn send(&mut self, to: &str, body: &str) {
println!("email a {to}: {body}");
}
}
struct Order {
id: u32,
email: String,
total_cents: u32,
}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) {
let body = format!("Ordine #{} confermato", order.id);
mailer.send(&order.email, &body);
if order.total_cents >= 10_000 {
mailer.send("[email protected]", &format!("Ordine grande #{}", 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::*;
// Un fake: non manda niente, si limita ad annotare ogni chiamata.
#[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(), "Ordine grande #8".to_string()));
}
}

Output:

email a [email protected]: Ordine #7 confermato
email a [email protected]: Ordine #8 confermato
email a [email protected]: Ordine grande #8
▶ Provalo tu

Questo esempio non usa crate esterni, quindi nel Playground puoi anche scegliere Test dal menu accanto a Run:

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

A confirm_order non importa quale mailer riceve, e non lo sa nemmeno. Tutto il trucco è qui. La stessa idea funziona per un trait Clock (nei test usi un orologio fermo alle 21), un PaymentGateway o un trait Storage che nei test si appoggia a una HashMap. Farai pratica con la versione dell’orologio più sotto.

Scrivere i fake a mano va bene per uno o due trait. Quando sono tanti, il crate mockall (in inglese) può generarli per te. Aggiungilo come dev-dependency (dipendenza di sviluppo), così viene usato solo per test ed esempi (Cargo e crate spiega le sezioni di Cargo.toml):

Terminal window
cargo add --dev mockall

Metti #[automock] sul trait, e mockall crea un tipo MockMailer. La parte cfg_attr(test, ...) significa «fallo solo durante i test», così il mock non finisce mai nella tua build vera. Feature di Cargo e #[cfg] spiega cfg più nel dettaglio.

src/orders.rs
#[cfg(test)]
use mockall::automock;
#[cfg_attr(test, automock)]
pub trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Order e confirm_order sono uguali a prima.
#[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 == "Ordine grande #8")
.times(1)
.return_const(());
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut mailer);
}
}

Ogni expect_send() è un’aspettativa (expectation): «mi aspetto che send venga chiamato con argomenti che corrispondono a withf, esattamente una volta». return_const(()) dice che cosa restituisce il mock (qui, niente). Quando il mock viene distrutto alla fine del test, mockall controlla che ogni aspettativa sia stata rispettata.

Per vederlo scoprire un bug, cambia la regola in confirm_order in order.total_cents > 12_000. Ora l’email all’ufficio vendite non parte mai:

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

Il messaggio dice che l’aspettativa è stata soddisfatta 0 volte, meno dell’unica volta prevista.

Fake o mock? Comincia con un fake scritto a mano. È Rust semplice, facile da leggere, e non aggiunge dipendenze. Passa a mockall quando hai molti trait da simulare, o quando ti interessa davvero verificare «chiamato esattamente N volte con questi argomenti».

Un test normale controlla un esempio che hai scelto tu: «12.50 diventa 1250». Un property test (test basato sulle proprietà) enuncia una regola che dovrebbe valere per ogni input, e poi una libreria prova centinaia di input casuali per smentirla. È come assumere una persona molto paziente che prova ogni valore strano a cui non avevi pensato.

Le buone proprietà spesso hanno questa forma:

  • Fare una cosa due volte la annulla: invertire una lista due volte restituisce la lista originale.
  • Un viaggio di andata e ritorno restituisce il punto di partenza: parse(format(x)) == x.
  • Un certo fatto vale sempre: una lista ordinata ha la stessa lunghezza dell’input.

Aggiungi proptest (in inglese) con cargo add --dev proptest, e scrivi i test dentro la macro proptest!. Ogni riga nome in strategia dice che tipo di valori casuali generare:

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>() significa «qualsiasi i32», 0u32..10_000_000 significa un numero in quell’intervallo, e vec(..., 0..100) costruisce liste fino a 99 elementi. Di default ogni proprietà viene provata su 256 casi casuali. Dentro proptest! usa prop_assert_eq! invece di assert_eq!, così proptest può gestire bene il fallimento. Entrambi i test passano:

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

Nota che è lo stesso controllo del test lento con #[ignore] visto prima. Quel test provava tutti i dieci milioni di prezzi in 3 secondi. Il property test ne prova 256 a caso in un centesimo di secondo, quindi può girare ogni volta.

Ora facciamo un errore apposta. Qualcuno «migliora» format_price per stampare un separatore delle migliaia, come 1,000.00. È più bello da vedere, ma si dimentica di parse_price:

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

Ecco l’output reale di cargo test --test properties, leggermente accorciato:

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

Guarda minimal failing input: cents = 100000 (input minimo che fallisce). Il primo valore casuale che ha fallito era probabilmente qualcosa di disordinato come 7.384.912. Poi proptest lo riduce (shrinking): continua a provare valori più piccoli che falliscono ancora, e riporta il più semplice. 100000 centesimi sono esattamente 1.000,00 euro, il primo prezzo che riceve una virgola. Questo ti porta dritto al bug.

Proptest salva anche il caso che fallisce in un file .proptest-regressions accanto al test, e lo prova per primo a ogni esecuzione successiva. Fai il commit di quel file, così il bug non può tornare di nascosto. Se preferisci una libreria più piccola, quickcheck (in inglese) fa lo stesso lavoro con meno funzionalità.

Certi output sono lunghi: uno scontrino, un rapporto di errori, un grosso documento JSON. Scrivere assert_eq! con tutto il testo atteso è faticoso. Uno snapshot test salva l’output in un file la prima volta, e nelle esecuzioni successive confronta il nuovo output con quella «foto» salvata.

Con insta (in inglese; cargo add --dev insta), il test è una riga sola:

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()));
}

La prima esecuzione fallisce apposta e scrive un file .snap.new da rivedere. Dopo che l’hai accettato (con cargo insta review dello strumento cargo-insta, oppure rinominando il file in .snap), resta 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

Più avanti, se qualcuno cambia "TOTAL" in "Total", il test fallisce con un diff chiaro:

-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`

Se la modifica era voluta, accetti il nuovo snapshot. Se no, hai appena trovato un bug. Gli snapshot sono ottimi per i controlli del tipo «questo non deve cambiare per sbaglio», ma non ti dicono se l’output è giusto. Leggi con attenzione il primo snapshot prima di accettarlo.

Quando vuoi sapere se il codice è veloce, misuralo. Tirare a indovinare porta fuori strada sorprendentemente spesso. Nemmeno cronometrare il codice dentro un #[test] funziona bene: i test vengono compilati senza ottimizzazioni, e una sola esecuzione è troppo rumorosa.

criterion (in inglese) è lo strumento abituale su Rust stabile. Esegue il tuo codice molte volte, fa la statistica al posto tuo e ricorda l’ultimo risultato, così può dirti se sei diventato più veloce o più lento. Aggiungilo con cargo add --dev criterion, poi parla a Cargo del file di benchmark e disattiva l’harness integrato:

[[bench]]
name = "stock"
harness = false

Questo benchmark si chiede: per controllare se un codice prodotto è in magazzino, è più veloce un Vec o un HashSet, con 1.000 codici?

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 codici prodotto, come "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"); // il caso peggiore per un Vec: l'ultimo
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(...) è il codice che viene misurato. std::hint::black_box nasconde un valore all’ottimizzatore. Senza, il compilatore potrebbe accorgersi che la risposta non cambia mai, calcolarla una volta sola, e tu misureresti il nulla.

Eseguilo con cargo bench, che compila sempre in modalità ottimizzata (release). Sul tuo computer i numeri saranno diversi:

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

Come leggerlo: i tre tempi sono un intervallo. Quello in mezzo è la stima migliore, e i due esterni dicono «il valore reale è molto probabilmente compreso tra questi». Qui l’HashSet impiega circa 19 nanosecondi e il Vec circa 2,4 microsecondi, quindi in questo caso l’insieme è più di 100 volte più veloce. Gli «outliers» (valori anomali) sono poche esecuzioni insolitamente lente o veloci, spesso causate da altri programmi sul tuo computer. Qualcuno è normale.

Eseguilo di nuovo dopo una modifica, e criterion fa il confronto con l’ultima esecuzione:

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.

La variazione è sotto l’1% e potrebbe essere solo rumore, quindi criterion dice «No change» (nessun cambiamento). Scrive anche un report HTML in target/criterion/. Per capire quando (e quando no) preoccuparti della velocità, vedi Prestazioni.

Nel codice più vecchio potresti trovare anche funzioni #[bench]. Usano il test::Bencher integrato, che funziona solo con il compilatore nightly:

#![feature(test)] // solo nightly
extern crate test;
#[bench]
fn bench_total(b: &mut test::Bencher) {
b.iter(|| (1..=1000u64).sum::<u64>());
}

Su Rust stabile, usa criterion.

Quando hai dei test, altri due strumenti ti aiutano. Li installi con cargo install, come gli altri strumenti di Strumenti e configurazione.

cargo-nextest è un esecutore di test più veloce. Esegue ogni test in un processo separato, mostra un output più chiaro e può ripetere i test instabili. Una cosa da sapere: non esegue i doc test, quindi lancia anche cargo test --doc.

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

La copertura (coverage) ti dice quali righe i tuoi test hanno davvero eseguito. cargo-llvm-cov usa il supporto alla copertura integrato nel compilatore e funziona su Linux, macOS e Windows:

Terminal window
cargo install cargo-llvm-cov
cargo llvm-cov # tabella riassuntiva nel terminale
cargo llvm-cov --html # un report navigabile in target/llvm-cov/html

cargo-tarpaulin è un’alternativa più vecchia e popolare. Funziona al meglio su Linux ed è una scelta comune nella CI:

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

Considera la copertura una mappa, non un punteggio. Ti mostra il codice che nessun test tocca mai. Non può dirti se i tuoi assert controllano la cosa giusta. Una copertura dell’80% con buoni assert batte il 100% con assert deboli.

Nella CI (integrazione continua: GitHub Actions, GitLab e così via), un buon punto di partenza sono gli stessi tre comandi che esegui in locale, così un controllo fallito significa la stessa cosa ovunque:

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

Test di integrazione per un crate che è solo un eseguibile. Se il tuo progetto ha solo src/main.rs, i file in tests/ non possono importare niente da lì:

error[E0432]: unresolved import `todo`
--> tests/tasks.rs:1:5
|
1 | use todo::add_task;
| ^^^^ use of unresolved module or unlinked crate `todo`

Il compilatore non riesce a risolvere l’import todo, perché non esiste nessun crate di libreria con quel nome. Soluzione: sposta la logica in src/lib.rs e tieni main.rs piccolo. A quel punto main.rs chiama la libreria, e lo stesso fanno i tuoi test. Così il codice diventa anche più facile da riusare.

Mettere le funzioni di supporto condivise in tests/common.rs. Ogni file .rs direttamente dentro tests/ viene trattato come un file di test a sé, quindi nell’output compare una sezione extra inutile:

Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 tests

Soluzione: usa tests/common/mod.rs, e mod common; in ogni file di test che ne ha bisogno.

Crate di test in [dependencies]. proptest, mockall, insta e criterion vanno sotto [dev-dependencies] (usa cargo add --dev). Altrimenti ogni utente del tuo crate li scarica e li compila.

Test che condividono lo stato. I test girano in parallelo. Se due test scrivono lo stesso file o cambiano la stessa variabile d’ambiente, falliscono a caso. Dai a ogni test la sua cartella temporanea (il crate tempfile aiuta) o, come ultima risorsa, esegui con --test-threads=1.

Benchmark senza black_box, o dentro un test. L’ottimizzatore può eliminare il lavoro il cui risultato non viene usato, e un tempo misurato in un #[test] in modalità debug dice poco. Usa criterion con black_box.

Questa funzione non si può testare in modo affidabile, perché chiede sempre l’ora all’orologio vero:

fn greeting() -> &'static str {
let hour = current_hour(); // legge l'ora di sistema
match hour {
5..=11 => "Buongiorno",
12..=17 => "Buon pomeriggio",
_ => "Buonasera",
}
}

Riscrivila perché riceva un trait Clock con un solo metodo, fn hour(&self) -> u64. Scrivi un FixedClock che restituisce sempre la stessa ora, e testa i limiti: 11 e 12, 17 e 18. Suggerimento: basta struct FixedClock(u64);. L’app vera passerebbe un SystemClock che legge l’ora.

Mostra la soluzione
trait Clock {
fn hour(&self) -> u64; // da 0 a 23
}
// Un orologio che dice sempre la stessa ora. Perfetto per i test.
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 => "Buongiorno",
12..=17 => "Buon pomeriggio",
_ => "Buonasera",
}
}
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)), "Buongiorno");
assert_eq!(greeting(&FixedClock(12)), "Buon pomeriggio");
}
#[test]
fn evening_starts_at_six() {
assert_eq!(greeting(&FixedClock(17)), "Buon pomeriggio");
assert_eq!(greeting(&FixedClock(18)), "Buonasera");
}
#[test]
fn very_early_is_still_evening() {
assert_eq!(greeting(&FixedClock(3)), "Buonasera");
}
}

Output:

8:00 -> Buongiorno
12:00 -> Buon pomeriggio
21:00 -> Buonasera
▶ Provalo tu

greeting non decide più da dove arriva l’ora: lo decide chi la chiama. Questo solo cambiamento rende ogni ora testabile in pochi millisecondi. Scegli Test nel menu del Playground e tutti e 3 i test passano.

2. Trova la regola, poi falla controllare a proptest

Sezione intitolata “2. Trova la regola, poi falla controllare a proptest”

Ecco una funzione che trasforma un tag di un blog come " Web Dev " in "web-dev":

fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}

Scrivi due property test con proptest: normalizzare un tag già normalizzato non cambia niente, e il risultato non contiene mai spazi bianchi. Suggerimento: la strategia ".*" genera stringhe casuali (è un’espressione regolare). Per «niente spazi bianchi» funziona prop_assert!(!s.contains(char::is_whitespace)).

Mostra la soluzione
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));
}
}
}

Output:

"Rust" -> "rust"
" Web Dev " -> "web-dev"
"GAME dev" -> "game-dev"
▶ Provalo tu

Nel Playground Run funziona, ma Test no, perché il Playground non ha proptest. Nel tuo progetto aggiungilo con cargo add --dev proptest ed esegui cargo test:

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

Le stringhe casuali comprendono tabulazioni, a capo e lettere non inglesi, che probabilmente non scriveresti mai in un test fatto a mano. «Farlo due volte equivale a farlo una volta» è una proprietà che vale la pena controllare per qualsiasi funzione di pulizia.

  • I test unitari vanno in #[cfg(test)] mod tests accanto al codice, i test di integrazione in tests/ (le funzioni di supporto condivise in tests/common/mod.rs), e i doc test nei commenti ///.
  • Usa #[ignore] per i test lenti, eseguili con cargo test -- --ignored, filtra per nome con cargo test un_nome e vedi l’output di println! con -- --nocapture.
  • Per testare codice che parla con il mondo esterno, metti quella dipendenza dietro un trait e passa un fake nei test. Usa mockall quando i fake scritti a mano diventano noiosi.
  • I property test (proptest) controllano una regola su centinaia di input casuali e riducono un fallimento all’esempio più semplice. Gli snapshot test (insta) scoprono le modifiche accidentali negli output grandi.
  • Misura la velocità con criterion e cargo bench, e avvolgi gli input in std::hint::black_box. #[bench] richiede nightly.
  • cargo-nextest esegue i test più in fretta; cargo-llvm-cov e cargo-tarpaulin mostrano quali righe coprono i tuoi test.

Verifica veloce

1. Diversi file in tests/ hanno bisogno della stessa funzione di supporto. Dove va messa?

2. Che cosa fa cargo test -- --ignored?

3. Una proprietà di proptest fallisce. Che cosa riporta proptest come input minimo che fallisce?

4. Perché i benchmark di criterion avvolgono i valori in std::hint::black_box?

Punteggio: 0 / 4