Come Scrivere dei Test
I test sono funzioni di Rust che verificano che il codice non di test funzioni nel modo previsto. I corpi delle funzioni di test eseguono tipicamente queste tre azioni:
- Impostare i dati o lo stato necessari.
- Eseguire il codice che si desidera testare.
- Verificare che i risultati siano quelli attesi.
Vediamo le funzionalità che Rust mette a disposizione specificamente per
scrivere test che eseguono queste azioni, come l’attributo test
, alcune macro
e l’attributo should_panic
.
Strutturare le Funzioni di Test
Nella sua forma più semplice, un test in Rust è una funzione annotata con
l’attributo test
. Gli attributi sono metadati relativi a pezzi di codice Rust;
un esempio è l’attributo derive
che abbiamo usato con le struct nel Capitolo
5. Per trasformare una funzione in una funzione di test, aggiungi #[test]
nella riga prima di fn
. Quando esegui i tuoi test con il comando cargo test
,
Rust costruisce un eseguibile di test che esegue le funzioni annotate e riporta
se ogni funzione di test passa o fallisce.
Ogni volta che creiamo un nuovo progetto di libreria con Cargo, viene generato automaticamente un modulo di test con una funzione di test al suo interno. Questo modulo ti fornisce un modello per scrivere i tuoi test, in modo da non dover cercare la struttura e la sintassi esatta ogni volta che inizi un nuovo progetto. Puoi aggiungere tutte le funzioni di test e tutti i moduli di test che vuoi!
Esploreremo alcuni aspetti del funzionamento dei test sperimentando con il test predefinito prima di testare effettivamente il codice. Poi scriveremo alcuni test reali che richiamano il codice che abbiamo scritto e verificano che il suo comportamento sia corretto.
Creiamo un nuovo progetto di libreria chiamato addizione
che aggiungerà due
numeri:
$ cargo new addizione --lib
Created library `addizione` project
$ cd addizione
Il contenuto del file src/lib.rs della tua libreria addizione
dovrebbe
essere come il Listato 11-1.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
cargo new
Il file inizia con un esempio di funzione add
(aggiungi), in modo da avere
qualcosa da testare.
Per ora, concentriamoci solo sulla funzione it_works
(funziona). Nota
l’annotazione #[test]
: questo attributo indica che si tratta di una funzione
di test, in modo che il test runner sappia che deve trattare questa funzione
come un test. Potremmo anche avere funzioni non di test nel modulo tests
per
aiutare a configurare scenari comuni o eseguire operazioni comuni, quindi
dobbiamo sempre indicare quali funzioni sono di test.
Il corpo della funzione di esempio utilizza la macro assert_eq!
per verificare
che result
, che contiene il risultato della chiamata a add
con 2 e 2, sia
uguale a 4. Questa asserzione serve come esempio del formato di un test tipico.
Eseguiamola per vedere se il test passa.
Il comando cargo test
esegue tutti i test del nostro progetto, come mostrato
nel Listato 11-2.
$ cargo test
Compiling addizione v0.1.0 (file:///progetti/addizione)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.90s
Running unittests src/lib.rs (/target/debug/deps/addizione-943d072b5b568c79)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests addizione
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo ha compilato ed eseguito il test. Vediamo la riga running 1 test
. La
riga successiva mostra il nome della funzione di test generata, chiamata
tests::it_works
, e che il risultato dell’esecuzione di quel test è ok
. Il
riepilogo complessivo test result: ok.
significa che tutti i test sono
passati, e la parte che recita 1 passed; 0 failed
tiene il conto del numero di
test che sono passati o falliti.
È possibile contrassegnare un test come da ignorare in modo che non venga
eseguito in una particolare istanza; ne parleremo nella sezione “Ignorare test
se non specificamente richiesti” più avanti in questo
capitolo. Poiché non l’abbiamo fatto qui, il riepilogo mostra 0 ignored
.
Possiamo anche passare un argomento al comando cargo test
per eseguire solo i
test il cui nome corrisponda a una stringa; questo si chiama filtraggio e lo
tratteremo nella sezione “Eseguire un sottoinsieme di test in base al
nome”. In questo caso non abbiamo filtrato i test in
esecuzione, quindi la fine del riepilogo mostra 0 filtered out
.
La statistica 0 measured
è per i test di benchmark che misurano le
prestazioni. I test di benchmark, al momento, sono disponibili solo nelle
nightly di Rust. Per saperne di più, consulta la documentazione sui test di
benchmark.
La parte successiva dell’output di test che inizia con Doc-tests addizione
è
per i risultati di qualsiasi test sulla documentazione. Non abbiamo ancora test
sulla documentazione, ma Rust può compilare qualsiasi esempio di codice che
appare nella nostra documentazione. Questa funzione aiuta a mantenere
sincronizzata la documentazione e il codice! Parleremo di come scrivere test
sulla documentazione nella sezione “Commenti di documentazione come
test” del Capitolo 14. Per ora, ignoreremo
l’output Doc-tests
.
Iniziamo a personalizzare il test in base alle nostre esigenze. Per prima cosa,
cambiamo il nome della funzione it_works
, ad esempio esplorazione
, in questo
modo:
File: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn esplorazione() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
Esegui nuovamente cargo test
. L’output ora mostra esplorazione
invece di
it_works
:
$ cargo test
Compiling addizione v0.1.0 (file:///progetti/addizione)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.90s
Running unittests src/lib.rs (/target/debug/deps/addizione-943d072b5b568c79)
running 1 test
test tests::esplorazione ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests addizione
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Ora aggiungeremo un altro test, ma questa volta faremo un test che fallisce! I
test falliscono quando qualcosa nella funzione di test va in panico. Ogni test
viene eseguito in un nuovo thread e quando il thread principale vede che un
thread di test fallisce, il test viene contrassegnato come fallito. Nel
Capitolo 9 abbiamo parlato di come il modo più semplice per mandare in panico un
programma sia quello di chiamare la macro panic!
. Inserisci il nuovo test come
una funzione di nome un_altra
, in modo che il tuo file src/lib.rs assuma
l’aspetto del Listato 11-3.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn esplorazione() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn un_altra() {
panic!("Fai fallire questo test");
}
}
panic!
Esegui di nuovo i test utilizzando cargo test
. L’output dovrebbe assomigliare
al Listato 11-4, che mostra come il nostro test esplorazione
sia passato e
un_altra
sia fallito.
$ cargo test
Compiling addizione v0.1.0 (file:///progetti/addizione)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.92s
Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)
running 2 tests
test tests::esplorazione ... ok
test tests::un_altra ... FAILED
failures:
---- tests::un_altra stdout ----
thread 'tests::un_altra' (4763) panicked at src/lib.rs:17:9:
Fai fallire questo test
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::un_altra
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Al posto di ok
, la riga test tests::un_altra
mostra FAILED
(fallito).
Tra i singoli risultati e il riepilogo compaiono due nuove sezioni: la prima
mostra il motivo dettagliato del fallimento di ogni test. In questo caso,
otteniamo il dettaglio che tests::un_altra
ha fallito perché è andato in
panico con il messaggio Fai fallire questo test
alla riga 17 del file
src/lib.rs. La sezione successiva elenca solo i nomi di tutti i test falliti,
il che è utile quando ci sono molti test e molti output dettagliati di test
falliti. Possiamo usare il nome di un test fallito per eseguire solo quel test e
più facilmente fare il debug del problema; parleremo più diffusamente dei modi
per eseguire i test nella sezione “Controllare come vengono eseguiti i
test”.
Alla fine viene visualizzata la riga di riepilogo: in generale, il risultato del
nostro test è FAILED
. Abbiamo avuto un test superato e un test fallito.
Ora che hai visto come appaiono i risultati dei test in diversi scenari, vediamo
alcune macro diverse da panic!
che sono utili nei test.
Verificare i Risultati Con assert!
La macro assert!
, fornita dalla libreria standard, è utile quando vuoi
assicurarti che una condizione in un test risulti essere vera, true
. Diamo
alla macro assert!
un argomento che valuta in un booleano. Se il valore è
true
, non succede nulla e il test passa. Se il valore è false
, la macro
assert!
chiama panic!
per far fallire il test. L’uso della macro assert!
ci aiuta a verificare che il nostro codice funzioni nel modo in cui intendiamo.
Nel Listato 5-15 del Capitolo 5 abbiamo utilizzato una struct Rettangolo
e
un metodo può_contenere
, che sono ripetuti qui nel Listato 11-5. Inseriamo
questo codice nel file src/lib.rs e scriviamo alcuni test utilizzando la macro
assert!
.
#[derive(Debug)]
struct Rettangolo {
larghezza: u32,
altezza: u32,
}
impl Rettangolo {
fn può_contenere(&self, altro: &Rettangolo) -> bool {
self.larghezza > altro.larghezza && self.altezza > altro.altezza
}
}
Rettangolo
e il suo metodo può_contenere
del Capitolo 5Il metodo può_contenere
restituisce un booleano, il che significa che è un
caso d’uso perfetto per la macro assert!
. Nel Listato 11-6, scriviamo un test
che utilizza il metodo può_contenere
creando un’istanza di Rettangolo
che ha
una larghezza di 8 e un’altezza di 7 e affermando che può contenere un’altra
istanza di Rettangolo
che ha una larghezza di 5 e un’altezza di 1.
#[derive(Debug)]
struct Rettangolo {
larghezza: u32,
altezza: u32,
}
impl Rettangolo {
fn può_contenere(&self, altro: &Rettangolo) -> bool {
self.larghezza > altro.larghezza && self.altezza > altro.altezza
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grande_contiene_piccolo() {
let grande = Rettangolo {
larghezza: 8,
altezza: 7,
};
let piccolo = Rettangolo {
larghezza: 5,
altezza: 1,
};
assert!(grande.può_contenere(&piccolo));
}
}
può_contenere
che verifica se un rettangolo più grande può effettivamente contenere un rettangolo più piccoloNota la riga use super::*;
all’interno del modulo tests
. Il modulo tests
è
un modulo normale che segue le solite regole di visibilità che abbiamo trattato
nel Capitolo 7 nella sezione “Percorsi per Fare Riferimento a un Elemento
nell’Albero dei Moduli”. Poiché il modulo tests
è un modulo interno, dobbiamo portare il
codice del modulo esterno che vogliamo testare nello scope del modulo interno.
Usiamo un glob in questo caso, in modo che tutto ciò che definiamo nel modulo
esterno sia disponibile per questo modulo tests
.
Abbiamo chiamato il nostro test grande_contiene_piccolo
e abbiamo creato le
due istanze di Rettangolo
di cui abbiamo bisogno. Poi abbiamo chiamato la
macro assert!
e le abbiamo passato il risultato della chiamata
grande.può_contenere(&piccolo)
. Questa espressione dovrebbe restituire true
,
quindi il nostro test dovrebbe passare. Scopriamolo!
$ cargo test
Compiling rettangolo v0.1.0 (file:///progetti/rettangolo)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s
Running unittests src/lib.rs (target/debug/deps/rettangolo-f3dbc7f03c0e31f1)
running 1 test
test tests::grande_contiene_piccolo ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rettangolo
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Passa davvero! Aggiungiamo un altro test, questa volta per verificare che un rettangolo più piccolo non può contenere un rettangolo più grande:
File: src/lib.rs
#[derive(Debug)]
struct Rettangolo {
larghezza: u32,
altezza: u32,
}
impl Rettangolo {
fn può_contenere(&self, altro: &Rettangolo) -> bool {
self.larghezza > altro.larghezza && self.altezza > altro.altezza
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grande_contiene_piccolo() {
// --taglio--
let grande = Rettangolo {
larghezza: 8,
altezza: 7,
};
let piccolo = Rettangolo {
larghezza: 5,
altezza: 1,
};
assert!(grande.può_contenere(&piccolo));
}
#[test]
fn piccolo_non_contiene_grande() {
let grande = Rettangolo {
larghezza: 8,
altezza: 7,
};
let piccolo = Rettangolo {
larghezza: 5,
altezza: 1,
};
assert!(!piccolo.può_contenere(&grande));
}
}
Poiché il risultato corretto della funzione può_contenere
in questo caso è
false
, dobbiamo negare questo risultato prima di passarlo alla macro
assert!
. Di conseguenza, il nostro test passerà se può_contenere
restituisce
false
:
$ cargo test
Compiling rettangolo v0.1.0 (file:///progetti/rettangolo)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
Running unittests src/lib.rs (target/debug/deps/rettangolo-f3dbc7f03c0e31f1)
running 2 tests
test tests::grande_contiene_piccolo ... ok
test tests::piccolo_non_contiene_grande ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rettangolo
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Due test superati! Ora vediamo cosa succede ai risultati dei nostri test quando
introduciamo un bug nel nostro codice. Cambieremo l’implementazione del metodo
può_contenere
sostituendo il segno maggiore (>
) con il segno minore (<
)
quando confronta le larghezze:
#[derive(Debug)]
struct Rettangolo {
larghezza: u32,
altezza: u32,
}
// --taglio--
impl Rettangolo {
fn può_contenere(&self, altro: &Rettangolo) -> bool {
self.larghezza < altro.larghezza && self.altezza > altro.altezza
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grande_contiene_piccolo() {
let grande = Rettangolo {
larghezza: 8,
altezza: 7,
};
let piccolo = Rettangolo {
larghezza: 5,
altezza: 1,
};
assert!(grande.può_contenere(&piccolo));
}
#[test]
fn piccolo_non_contiene_grande() {
let grande = Rettangolo {
larghezza: 8,
altezza: 7,
};
let piccolo = Rettangolo {
larghezza: 5,
altezza: 1,
};
assert!(!piccolo.può_contenere(&grande));
}
}
L’esecuzione dei test produce ora il seguente risultato:
$ cargo test
Compiling rettangolo v0.1.0 (file:///progetti/rettangolo)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
Running unittests src/lib.rs (target/debug/deps/rettangolo-f3dbc7f03c0e31f1)
running 2 tests
test tests::grande_contiene_piccolo ... FAILED
test tests::piccolo_non_contiene_grande ... ok
failures:
---- tests::grande_contiene_piccolo stdout ----
thread 'tests::grande_contiene_piccolo' (6902) panicked at src/lib.rs:31:9:
assertion failed: grande.può_contenere(&piccolo)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::grande_contiene_piccolo
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Poiché grande.larghezza
è 8
e piccolo.larghezza
è 5
, il confronto delle
larghezze in può_contenere
ora restituisce false
: 8 non è inferiore a 5.
Testare l’Uguaglianza Con assert_eq!
e assert_ne!
Un modo comune per verificare le funzionalità è quello di testare l’uguaglianza
tra il risultato del codice in esame e il valore che ti aspetti che il codice
restituisca. Potresti farlo utilizzando la macro assert!
e passandole
un’espressione con l’operatore ==
. Tuttavia, questo è un test così comune che
la libreria standard fornisce una coppia di macro, assert_eq!
e assert_ne!
,
per eseguire questo test in modo più conveniente. Queste macro confrontano due
argomenti per l’uguaglianza o l’ineguaglianza, rispettivamente. Inoltre,
stampano i due valori se l’asserzione fallisce, il che rende più facile vedere
il perché il test è fallito; al contrario, la macro assert!
indica solo che
ha ottenuto un valore false
per l’espressione ==
, senza stampare i valori
che hanno portato al valore false
.
Nel Listato 11-7, scriviamo una funzione chiamata aggiungi_due
che aggiunge
2
al suo parametro, e poi testiamo questa funzione usando la macro
assert_eq!
pub fn aggiungi_due(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aggiungere_due() {
let risultato = aggiungi_due(2);
assert_eq!(risultato, 4);
}
}
aggiungi_due
utilizzando la macro assert_eq!
Controlliamo che passi!
$ cargo test
Compiling addizione v0.1.0 (file:///progetti/addizione)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.00s
Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)
running 1 test
test tests::aggiungere_due ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests addizione
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Abbiamo creato una variabile chiamata risultato
che contiene il risultato
della chiamata a aggiungi_due(2)
. Poi passiamo risultato
e 4
come
argomenti alla macro assert_eq!
. La riga di output per questo test è test tests::aggiungere_due ... ok
, e il testo ok
indica che il nostro test è
passato!
Introduciamo un bug nel nostro codice per vedere come appare assert_eq!
quando
fallisce. Cambia l’implementazione della funzione aggiungi_due
per aggiungere
invece 3
:
pub fn aggiungi_due(a: u64) -> u64 {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aggiungere_due() {
let risultato = aggiungi_due(2);
assert_eq!(risultato, 4);
}
}
Esegui nuovamente i test:
$ cargo test
Compiling addizione v0.1.0 (file:///progetti/addizione)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.30s
Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)
running 1 test
test tests::aggiungere_due ... FAILED
failures:
---- tests::aggiungere_due stdout ----
thread 'tests::aggiungere_due' (5229) panicked at src/lib.rs:14:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::aggiungere_due
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Il nostro test ha rilevato il bug! Il test tests::aggiungere_due
è fallito e
il messaggio ci dice che l’asserzione fallita era left == right
(sinistra/destra) e quali sono i valori left
e right
. Questo messaggio
ci aiuta a iniziare il debug: l’argomento left
, dove avevamo il risultato
della chiamata a aggiungi_due(2)
, era 5
ma l’argomento right
era 4
. Puoi
immaginarti quanto questo sia particolarmente utile quando abbiamo molti test in
corso.
Nota che in alcuni linguaggi e framework di test, i parametri delle funzioni di
asserzione di uguaglianza sono chiamati expected
(atteso) e actual
(effettivo) e l’ordine in cui specifichiamo gli argomenti è importante.
Tuttavia, in Rust, sono chiamati left
e right
e l’ordine in cui
specifichiamo il valore che ci aspettiamo e il valore che il codice produce non
ha importanza. Potremmo scrivere l’asserzione in questo test come assert_eq!(4, risultato)
, che risulterebbe nello stesso messaggio di fallimento che mostra
assertion `left == right` failed
.
La macro assert_ne!
ha successo se i due valori che le diamo non sono uguali e
fallisce se sono uguali. Questa macro è molto utile nei casi in cui non siamo
sicuri di quale sarà il valore, ma sappiamo quale valore sicuramente non
dovrebbe essere. Ad esempio, se stiamo testando una funzione che ha la garanzia
di cambiare il suo input in qualche modo, ma il modo in cui l’input viene
cambiato dipende dal giorno della settimana in cui eseguiamo i test, la cosa
migliore da asserire potrebbe essere che l’output della funzione non è uguale
all’input.
Sotto la superficie, le macro assert_eq!
e assert_ne!
utilizzano
rispettivamente gli operatori ==
e !=
. Quando le asserzioni falliscono,
queste macro stampano i loro argomenti utilizzando la formattazione di debug, il
che significa che i valori confrontati devono implementare i trait PartialEq
e Debug
. Tutti i type primitivi e la maggior parte dei type della libreria
standard implementano questi trait. Per le struct e le enum che definisci
tu stesso, dovrai implementare PartialEq
per asserire l’uguaglianza di tali
type. Dovrai anche implementare Debug
per stampare i valori quando
l’asserzione fallisce. Poiché entrambi i trait sono derivabili, come
menzionato nel Listato 5-12 nel Capitolo 5, questo è solitamente semplice come
aggiungere l’annotazione #[derive(PartialEq, Debug)]
alla definizione della
struct o dell’enum. Vedi l’Appendice C, “Trait
derivabili”, per ulteriori dettagli su questi
e altri trait derivabili.
Aggiungere Messaggi di Errore Personalizzati
Puoi anche aggiungere un messaggio personalizzato da stampare insieme al
messaggio di fallimento come argomenti opzionali alle macro assert!
,
assert_eq!
e assert_ne!
. Qualsiasi argomento specificato dopo gli argomenti
obbligatori viene passato alla macro format!
(di cui si parla in “Concatenare
con l’Operatore +
o la Macro format!
” nel
Capitolo 8), quindi puoi passare una stringa di formato che contenga dei
segnaposto {}
e dei valori da inserire in quei segnaposto. I messaggi
personalizzati sono utili per documentare il significato di un’asserzione;
quando un test fallisce, avrai un’idea più precisa del problema del codice.
Ad esempio, supponiamo di avere una funzione che saluta le persone per nome e vogliamo verificare che il nome che passiamo nella funzione appaia nell’output:
File: src/lib.rs
pub fn saluto(nome: &str) -> String {
format!("Ciao {nome}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saluto_contiene_nome() {
let risultato = saluto("Carol");
assert!(risultato.contains("Carol"));
}
}
I requisiti per questo programma non sono ancora stati concordati e siamo
abbastanza sicuri che il testo Ciao
all’inizio del saluto cambierà. Abbiamo
deciso di non dover aggiornare il test quando i requisiti cambiano, quindi
invece di verificare l’esatta uguaglianza con il valore restituito dalla
funzione saluto
, asseriremo che l’output debba contenere il testo del
parametro di input.
Ora introduciamo un bug in questo codice cambiando saluto
per non includere
nome
e vedere come si presenta il fallimento del test:
pub fn saluto(nome: &str) -> String {
String::from("Ciao!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saluto_contiene_nome() {
let risultato = saluto("Carol");
assert!(risultato.contains("Carol"));
}
}
L’esecuzione di questo test produce il seguente risultato:
$ cargo test
Compiling saluto v0.1.0 (file:///progetti/saluto)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.15s
Running unittests src/lib.rs (target/debug/deps/saluto-9c4c14b7b4719873)
running 1 test
test tests::saluto_contiene_nome ... FAILED
failures:
---- tests::saluto_contiene_nome stdout ----
thread 'tests::saluto_contiene_nome' (10833) panicked at src/lib.rs:14:9:
assertion failed: risultato.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::saluto_contiene_nome
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Questo risultato indica solo che l’asserzione è fallita e su quale riga si trova
l’asserzione. Un messaggio di fallimento più utile stamperebbe il valore della
funzione saluto
. Aggiungiamo un messaggio di fallimento personalizzato
composto da una stringa di formato con un segnaposto riempito con il valore
effettivo ottenuto dalla funzione saluto
:
pub fn saluto(nome: &str) -> String {
String::from("Ciao!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saluto_contiene_nome() {
let risultato = saluto("Carol");
assert!(
risultato.contains("Carol"),
"Saluto non contiene un nome, il valore era `{risultato}`"
);
}
}
Ora, quando eseguiamo il test, otterremo un messaggio di errore più informativo:
$ cargo test
Compiling saluto v0.1.0 (file:///progetti/saluto)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.43s
Running unittests src/lib.rs (target/debug/deps/saluto-00f50f06a08e754d)
running 1 test
test tests::saluto_contiene_nome ... FAILED
failures:
---- tests::saluto_contiene_nome stdout ----
thread 'tests::saluto_contiene_nome' (11432) panicked at src/lib.rs:13:9:
Saluto non contiene un nome, il valore era `Ciao!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::saluto_contiene_nome
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Possiamo vedere il valore effettivamente ottenuto nell’output del test, il che ci aiuterebbe a fare il debug di ciò che è accaduto invece di ciò che ci aspettavamo che accadesse.
Verificare i Casi di Panico Con should_panic
Oltre a verificare i valori di ritorno, è importante controllare che il nostro
codice gestisca le condizioni di errore come ci aspettiamo. Ad esempio,
considera il type Ipotesi
che abbiamo creato nel Capitolo 9, Listato 9-13.
Altro codice che utilizza Ipotesi
dipende dalla garanzia che le istanze di
Ipotesi
conterranno solo valori compresi tra 1 e 100. Possiamo scrivere un
test che verifichi che il tentativo di creare un’istanza di Ipotesi
con un
valore al di fuori di questo intervallo vada in panico.
Per farlo, aggiungiamo l’attributo should_panic
alla nostra funzione di test.
Il test passa se il codice all’interno della funzione va in panico; il test
fallisce se il codice all’interno della funzione non va in panico.
Il Listato 11-8 mostra un test che verifica che le condizioni di errore di
Ipotesi::new
si verifichino quando ce lo aspettiamo.
pub struct Ipotesi {
valore: i32,
}
impl Ipotesi {
pub fn new(valore: i32) -> Ipotesi {
if valore < 1 || valore > 100 {
panic!("L'ipotesi deve essere compresa tra 1 e 100, valore ottenuto: {valore}.");
}
Ipotesi { valore }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn maggiore_di_100() {
Ipotesi::new(200);
}
}
panic!
Inseriamo l’attributo #[should_panic]
dopo l’attributo #[test]
e prima della
funzione di test a cui si applica. Vediamo il risultato quando questo test viene
superato:
$ cargo test
Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s
Running unittests src/lib.rs (target/debug/deps/gioco_indovinello-bd151867e79a86ca)
running 1 test
test tests::maggiore_di_100 - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests gioco_indovinello
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Non male! Ora introduciamo un bug nel nostro codice rimuovendo la condizione per
cui la funzione new
va in panico se il valore è superiore a 100:
pub struct Ipotesi {
valore: i32,
}
// --taglio--
impl Ipotesi {
pub fn new(valore: i32) -> Ipotesi {
if valore < 1 {
panic!("L'ipotesi deve essere compresa tra 1 e 100, valore ottenuto: {valore}.");
}
Ipotesi { valore }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn maggiore_di_100() {
Ipotesi::new(200);
}
}
Quando eseguiamo il test nel Listato 11-8, questo fallirà:
$ cargo test
Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.19s
Running unittests src/lib.rs (target/debug/deps/gioco_indovinello-bd151867e79a86ca)
running 1 test
test tests::maggiore_di_100 - should panic ... FAILED
failures:
---- tests::maggiore_di_100 stdout ----
note: test did not panic as expected at src/lib.rs:24:8
failures:
tests::maggiore_di_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
In questo caso non riceviamo un messaggio molto utile, ma se guardiamo la
funzione di test, vediamo che è annotata con #[should_panic]
. Il fallimento
ottenuto significa che il codice della funzione di test non ha causato un
panico.
I test che utilizzano should_panic
possono essere imprecisi. Un test
should_panic
passerebbe anche se il test va in panico per un motivo diverso da
quello atteso. Per rendere i test should_panic
più precisi, possiamo
aggiungere un parametro opzionale expected
all’attributo should_panic
.
L’infrastruttura di test si assicurerà che il messaggio di fallimento contenga
il testo fornito. Per esempio, considera il codice modificato per Ipotesi
nel
Listato 11-9 dove la funzione new
va in panico con messaggi diversi a seconda
che il valore sia troppo piccolo o troppo grande.
pub struct Ipotesi {
valore: i32,
}
// --taglio--
impl Ipotesi {
pub fn new(valore: i32) -> Ipotesi {
if valore < 1 {
panic!(
"L'ipotesi deve essere maggiore di zero, valore fornito {valore}."
);
} else if valore > 100 {
panic!(
"L'ipotesi deve essere minore o uguale a 100, valore fornito {valore}."
);
}
Ipotesi { valore }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "minore o uguale a 100")]
fn maggiore_di_100() {
Ipotesi::new(200);
}
}
panic!
con un messaggio di panico contenente una sotto-stringa specificataQuesto test passerà perché il valore che abbiamo inserito nel parametro
expected
dell’attributo should_panic
è una sotto-stringa del messaggio con
cui la funzione Ipotesi::new
va in panico. Avremmo potuto specificare l’intero
messaggio di panico che ci aspettiamo, che in questo caso sarebbe stato
L’ipotesi deve essere minore o uguale a 100, valore fornito 200.
. Quello che
scegli di specificare dipende da quanto il messaggio di panico è unico o
dinamico e da quanto preciso vuoi che sia il tuo test. In questo caso, una
sotto-stringa del messaggio di panico è sufficiente per garantire che il codice
nella funzione di test esegua la parte con else if valore > 100
.
Per vedere cosa succede quando un test should_panic
con un messaggio
expected
fallisce, introduciamo nuovamente un bug nel nostro codice scambiando
i corpi dei blocchi if valore < 1
e else if valore > 100
:
pub struct Ipotesi {
valore: i32,
}
impl Ipotesi {
pub fn new(valore: i32) -> Ipotesi {
if valore < 1 {
panic!(
"L'ipotesi deve essere minore o uguale a 100, valore fornito {valore}."
);
} else if valore > 100 {
panic!(
"L'ipotesi deve essere maggiore di zero, valore fornito {valore}."
);
}
Ipotesi { valore }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "minore o uguale a 100")]
fn maggiore_di_100() {
Ipotesi::new(200);
}
}
Questa volta il test should_panic
fallirà:
$ cargo test
Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s
Running unittests src/lib.rs (target/debug/deps/gioco_indovinello-bd151867e79a86ca)
running 1 test
test tests::maggiore_di_100 - should panic ... FAILED
failures:
---- tests::maggiore_di_100 stdout ----
thread 'tests::maggiore_di_100' (14846) panicked at src/lib.rs:13:13:
L'ipotesi deve essere maggiore di zero, valore fornito 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "L'ipotesi deve essere maggiore di zero, valore fornito 200."
expected substring: "minore o uguale a 100"
failures:
tests::maggiore_di_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Il messaggio di fallimento indica che questo test è andato in panic come ci
aspettavamo, ma il messaggio di panico non includeva la stringa prevista minore o uguale a 100
. Il messaggio di panico che abbiamo ottenuto in questo caso è
stato L’ipotesi deve essere maggiore di zero, valore fornito 200
. Ora possiamo
iniziare a capire dove si trova il nostro bug!
Utilizzare Result<T, E>
nei Test
Tutti i test che abbiamo fatto finora vanno in panic quando falliscono.
Possiamo anche scrivere test che utilizzano Result<T, E>
! Ecco il test del
Listato 11-1, riscritto per utilizzare Result<T, E>
e restituire un Err
invece di andare in panico:
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("due più due non fa quattro"))
}
}
}
La funzione it_works
ora ha il type di ritorno Result<(), String>
. Nel
corpo della funzione, invece di richiamare la macro assert_eq!
, restituiamo
Ok())
quando il test passa e un Err
con una String
all’interno quando il
test fallisce.
Scrivere i test in modo che restituiscano un Result<T, E>
ti permette di usare
l’operatore punto interrogativo nel corpo dei test, il che può essere un modo
comodo per scrivere test che dovrebbero fallire se qualsiasi operazione al loro
interno restituisce una variante Err
.
Non puoi usare l’annotazione #[should_panic]
nei test che usano Result<T, E>
. Per verificare che un’operazione restituisce una variante Err
, non
usare l’operatore punto interrogativo sul valore Result<T, E>
, ma usa
assert!(valore.is_err())
.
Ora che conosci diversi modi per scrivere i test, vediamo cosa succede quando li
eseguiamo ed esploriamo le diverse opzioni che possiamo utilizzare con cargo test