Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

File: src/lib.rs
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);
    }
}
Listato 11-1: Il codice generato automaticamente da 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

Listato 11-2: L’output dell’esecuzione del test generato automaticamente

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.

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

    #[test]
    fn un_altra() {
        panic!("Fai fallire questo test");
    }
}
Listato 11-3: Aggiungere un secondo test che fallisce perché chiamiamo la macro 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`
Listato 11-4: Risultati dei test quando uno viene superato e uno fallisce

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

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
    }
}
Listato 11-5: La struct Rettangolo e il suo metodo può_contenere del Capitolo 5

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

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() {
        let grande = Rettangolo {
            larghezza: 8,
            altezza: 7,
        };
        let piccolo = Rettangolo {
            larghezza: 5,
            altezza: 1,
        };

        assert!(grande.può_contenere(&piccolo));
    }
}
Listato 11-6: Un test per può_contenere che verifica se un rettangolo più grande può effettivamente contenere un rettangolo più piccolo

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

File: src/lib.rs
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);
    }
}
Listato 11-7: Test della funzione 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.

File: src/lib.rs
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);
    }
}
Listato 11-8: Test che una condizione generi un 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.

File: src/lib.rs
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);
    }
}
Listato 11-9: Test per un panic! con un messaggio di panico contenente una sotto-stringa specificata

Questo 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