Comment écrire des tests

Les tests sont des fonctions Rust qui vérifient que le code qui n'est pas un test se comporte bien de la manière attendue. Les corps des fonctions de test effectuent généralement ces trois actions :

  1. Initialiser toutes les données ou les états,
  2. Lancer le code que vous voulez tester,
  3. Vérifier que les résultats correspondent bien à ce que vous souhaitez.

Découvrons les fonctionnalités spécifiques qu'offre Rust pour écrire des tests qui font ces actions, dont l'attribut test, quelques macros et l'attribut should_panic.

L'anatomie d'une fonction de test

Dans la forme la plus simple, un test en Rust est une fonction qui est marquée avec l'attribut test. Les attributs sont des métadonnées sur des parties de code Rust ; un exemple est l'attribut derive que nous avons utilisé sur les structures au chapitre 5. Pour transformer une fonction en une fonction de test, il faut ajouter #[test] dans la ligne avant le fn. Lorsque vous lancez vos tests avec la commande cargo test, Rust construit un binaire d'exécution de tests qui exécute les fonctions marquées avec l'attribut test et fait un rapport sur quelles fonctions ont réussi ou échoué.

Lorsque nous créons une nouvelle bibliothèque avec Cargo, un module de tests qui contient une fonction de test est automatiquement créé pour nous. Ce module vous aide à démarrer l'écriture de vos tests afin que vous n'ayez pas à chercher la structure et la syntaxe exacte d'une fonction de test à chaque fois que vous débutez un nouveau projet. Vous pouvez ajouter autant de fonctions de test et autant de modules de tests que vous le souhaitez !

Nous allons découvrir quelques aspects du fonctionnement des tests en expérimentant avec le modèle de tests généré pour nous, mais qui ne teste aucun code pour le moment. Ensuite, nous écrirons quelques tests plus proches de la réalité, qui utiliseront du code que nous avons écrit et qui valideront son bon comportement.

Commençons par créer un nouveau projet de bibliothèque que nous appellerons addition :

$ cargo new addition --lib
     Created library `addition` project
$ cd addition

Le contenu de votre fichier src/lib.rs dans votre bibliothèque addition devrait ressembler à l'encart 11-1.

Fichier : src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let resultat = 2 + 2;
        assert_eq!(resultat, 4);
    }
}

Encart 11-1 : le module de test et la fonction générés automatiquement par cargo new

Pour l'instant, ignorons les deux premières lignes et concentrons-nous sur la fonction pour voir comment elle fonctionne. Remarquez l'annotation #[test] avant la ligne fn : cet attribut indique que c'est une fonction de test, donc l'exécuteur de tests sait qu'il doit considérer cette fonction comme étant un test. Nous pouvons aussi avoir des fonctions qui ne font pas de tests dans le module tests afin de configurer des scénarios communs ou exécuter des opérations communes, c'est pourquoi nous devons indiquer quelles fonctions sont des tests en utilisant l'attribut #[test].

Le corps de la fonction utilise la macro assert_eq! pour vérifier que 2 + 2 vaut bien 4. Cette vérification sert d'exemple pour expliquer le format d'un test classique. Lançons-le pour vérifier si ce test est validé.

La commande cargo test lance tous les tests présents dans votre projet, comme le montre l'encart 11-2.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

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 adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Encart 11-2 : le résultat du lancement des tests sur le test généré automatiquement

Cargo a compilé et lancé le test. Après les lignes Compiling, Finished, et Running, on trouve la ligne running 1 test. La ligne suivante montre le nom de la fonction de test it_works, qui a été générée précédemment, et le résultat de l'exécution de ce test, ok. Le résumé général de l'exécution des tests s'affiche ensuite. Le texte test result: ok. signifie que tous les tests ont réussi, et la partie 1 passed; 0 failed compte le nombre total de tests qui ont réussi ou échoué.

Comme nous n'avons aucun test que nous avons marqué comme ignoré, le résumé affiche 0 ignored. Nous n'avons pas non plus filtré les tests qui ont été exécutés, donc la fin du résumé affiche 0 filtered out. Nous verrons comment ignorer et filtrer les tests dans la prochaine section, “Contrôler comment les tests sont exécutés”.

La statistique 0 measured sert pour des tests de benchmark qui mesurent les performances. Les tests de benchmark ne sont disponibles pour le moment que dans la version expérimentale de Rust (nightly), au moment de la rédaction. Rendez-vous sur la documentation sur les tests de benchmark pour en savoir plus.

La partie suivante du résultat des tests, qui commence par Doc-tests addition, concerne les résultats de tous les tests présents dans la documentation. Nous n'avons pas de tests dans la documentation pour le moment, mais Rust peut compiler tous les exemples de code qui sont présents dans la documentation de notre API. Cette fonctionnalité nous aide à garder synchronisés notre documentation et notre code ! Nous verrons comment écrire nos tests dans la documentation dans une section du chapitre 14. Pour le moment, nous allons ignorer la partie Doc-tests du résultat.

Changeons le nom de notre test pour voir comment cela change le résultat du test. Changeons le nom de la fonction it_works pour un nom différent, comme exploration ci-dessous :

Fichier : src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }
}

Lancez ensuite à nouveau cargo test. Le résultat affiche désormais exploration plutôt que it_works :

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Ajoutons un autre test, mais cette fois nous allons construire un test qui échoue ! Les tests échouent lorsque quelque chose dans la fonction de test panique. Chaque test est lancé dans une nouvelle tâche, et lorsque la tâche principale voit qu'une tâche de test a été interrompue par panique, le test est considéré comme ayant échoué. Nous avons vu la façon la plus simple de faire paniquer au chapitre 9, qui consiste à appeler la macro panic!. Ecrivez ce nouveau test, un_autre, de sorte que votre fichier src/lib.rs ressemble à ceci :

Fichier : src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn un_autre() {
        panic!("Fait échouer ce test");
    }
}

Encart 11-3 : ajout d'un second test qui va échouer car nous appelons la macro panic!

Lancez à nouveau les tests en utilisant cargo test. Le résultat devrait ressembler à l'encart 11-4, qui va afficher que notre test exploration a réussi et que un_autre a échoué.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::un_autre ... FAILED
test tests::exploration ... ok

failures:

---- tests::un_autre stdout ----
thread 'main' panicked at 'Fait échouer ce test', src/lib.rs:10:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::un_autre

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'

Encart 11-4 : les résultats de tests lorsque un test réussit et un autre test échoue

A la place du ok, la ligne test tests:un_autre affiche FAILED. Deux nouvelles sections apparaissent entre la liste des tests et le résumé : la première section affiche les raisons détaillées de chaque échec de test. Dans notre cas, un_autre a échoué car il a paniqué à 'Fait échouer ce test', qui est placé à la ligne 10 du fichier src/lib.rs. La partie suivante liste simplement les noms de tous les tests qui ont échoué, ce qui est utile lorsqu'il y a de nombreux tests et beaucoup de détails provenant des tests qui échouent. Nous pouvons utiliser le nom d'un test qui échoue pour lancer uniquement ce test afin de déboguer plus facilement ; nous allons voir plus de façons de lancer des tests dans la section suivante.

La ligne de résumé s'affiche à la fin : au final, le résultat de nos tests est au statut FAILED (échoué). Nous avons un test réussi et un test échoué.

Maintenant que vous avez vu à quoi ressemblent les résultats de tests dans différents scénarios, voyons d'autres macros que panic! qui nous serons utiles pour les tests.

Vérifier les résultats avec la macro assert!

La macro assert!, fournie par la bibliothèque standard, est utile lorsque vous voulez vous assurer qu'une condition dans un test vaut true. Nous fournissons à la macro assert! un argument qui donne un Booléen une fois interprété. Si la valeur est true, assert! ne fait rien et le test est réussi. Si la valeur est false, la macro assert! appelle la macro panic!, qui fait échouer le test. L'utilisation de la macro assert! nous aide à vérifier que notre code fonctionne bien comme nous le souhaitions.

Dans le chapitre 5, dans l'encart 5-15, nous avons utilisé une structure Rectangle et une méthode peut_contenir, qui sont recopiés dans l'encart 11-5 ci-dessous. Ajoutons ce code dans le fichier src/lib.rs et écrivons quelques tests en utilisant la macro assert!.

Fichier : src/lib.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn peut_contenir(&self, other: &Rectangle) -> bool {
        self.largeur > other.largeur && self.hauteur > other.hauteur
    }
}

Encart 11-5 : utilisation de la structure Rectangle et sa méthode peut_contenir du chapitre 5

La méthode peut_contenir retourne un Booléen, ce qui veut dire que c'est un cas parfait pour tester la macro assert!. Dans l'encart 11-6, nous écrivons un test qui s'applique sur la méthode peut_contenir en créant une instance de Rectangle qui a une largeur de 8 et une hauteur de 7, et qui vérifie qu'il peut contenir une autre instance de Rectangle qui a une largeur de 6 et une hauteur de 1.

Fichier : src/lib.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn peut_contenir(&self, other: &Rectangle) -> bool {
        self.largeur > other.largeur && self.hauteur > other.hauteur
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_grand_peut_contenir_un_petit() {
        let le_grand = Rectangle { largeur: 8, hauteur: 7 };
        let le_petit = Rectangle { largeur: 5, hauteur: 1 };

        assert!(le_grand.peut_contenir(&le_petit));
    }
}

Encart 11-6 : un test pour peut_contenir qui vérifie le cas où un grand rectangle peut contenir un plus petit rectangle

Remarquez que nous avons ajouté une nouvelle ligne à l'intérieur du module test : use super::*;. Le module tests est un module classique qui suit les règles de visibilité que nous avons vues au chapitre 7 dans la section “Les chemins pour désigner un élément dans l'arborescence de module”. Comme le module tests est un module interne, nous avons besoin de ramener le code à tester qui se trouve dans son module parent dans la portée interne du module. Nous utilisons ici un opérateur global afin que tout ce que nous avons défini dans le module parent soit disponible dans le module tests.

Nous avons nommé notre test un_grand_peut_contenir_un_petit, et nous avons créé les deux instances Rectangle que nous avions besoin. Ensuite, nous avons appelé la macro assert! et nous lui avons passé le résultat de l'appel à le_grand.peut_contenir(&le_petit). Cette expression est censée retourner true, donc notre test devrait réussir. Vérifions cela !

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::un_grand_peut_contenir_un_petit ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Il a réussi ! Ajoutons maintenant un autre test, qui vérifie cette fois qu'un petit rectangle ne peut contenir un rectangle plus grand :

Fichier : src/lib.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn peut_contenir(&self, other: &Rectangle) -> bool {
        self.largeur > other.largeur && self.hauteur > other.hauteur
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_grand_peut_contenir_un_petit() {
        // --snip--
        let le_grand = Rectangle {
            largeur: 8,
            hauteur: 7,
        };
        let le_petit = Rectangle {
            largeur: 5,
            hauteur: 1,
        };

        assert!(le_grand.peut_contenir(&le_petit));
    }

    #[test]
    fn un_petit_ne_peut_pas_contenir_un_plus_grand() {
        let le_grand = Rectangle {
            largeur: 8,
            hauteur: 7,
        };
        let le_petit = Rectangle {
            largeur: 5,
            hauteur: 1,
        };

        assert!(!le_petit.peut_contenir(&le_grand));
    }
}

Comme le résultat correct de la fonction peut_contenir dans ce cas doit être false, nous devons faire un négatif de cette fonction avant de l'envoyer à la macro assert!. Cela aura pour effet de faire réussir notre test si peut_contenir retourne false :

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::un_grand_peut_contenir_un_petit ... ok
test tests::un_petit_ne_peut_pas_contenir_un_plus_grand ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Voilà deux tests qui réussissent ! Maintenant, voyons ce qu'il se passe dans les résultats de nos tests lorsque nous introduisons un bogue dans notre code. Changeons l'implémentation de la méthode peut_contenir en remplaçant l'opérateur plus grand que par un plus petit que au moment de la comparaison des largeurs :

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

// -- partie masquée ici --
impl Rectangle {
    fn peut_contenir(&self, other: &Rectangle) -> bool {
        self.largeur < other.largeur && self.hauteur > other.hauteur
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_grand_peut_contenir_un_petit() {
        let le_grand = Rectangle {
            largeur: 8,
            hauteur: 7,
        };
        let le_petit = Rectangle {
            largeur: 5,
            hauteur: 1,
        };

        assert!(le_grand.peut_contenir(&le_petit));
    }

    #[test]
    fn un_petit_ne_peut_pas_contenir_un_plus_grand() {
        let le_grand = Rectangle {
            largeur: 8,
            hauteur: 7,
        };
        let le_petit = Rectangle {
            largeur: 5,
            hauteur: 1,
        };

        assert!(!le_petit.peut_contenir(&le_grand));
    }
}

Le lancement des tests donne maintenant le résultat suivant :

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::un_grand_peut_contenir_un_petit ... FAILED
test tests::un_petit_ne_peut_pas_contenir_un_plus_grand ... ok

failures:

---- tests::un_grand_peut_contenir_un_petit stdout ----
thread 'main' panicked at 'assertion failed: le_grand.can_hold(&le_petit)', src/lib.rs:28:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::un_grand_peut_contenir_un_petit

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'

Nos tests ont repéré le bogue ! Comme le_grand.largeur est 8 et le_petit.largeur est 5, la comparaison des largeurs dans peut_contenir retourne maintenant false : 8 n'est pas plus petit que 5.

Tester l'égalité avec les macros assert_eq! et assert_ne!

Une façon courante de tester des fonctionnalités est de comparer le résultat du code à tester par rapport à une valeur que vous souhaitez que le code retourne, afin de vous assurer qu'elles soient bien égales. Vous pouvez faire cela avec la macro assert! et en lui passant une expression qui utilise l'opérateur ==. Cependant, c'est un test si courant que la bibliothèque standard fournit une paire de macros (assert_eq! et assert_ne!) pour procéder à ce test plus facilement. Les macros comparent respectivement l'égalité ou la non égalité de deux arguments. Elles vont aussi afficher les deux valeurs si la vérification échoue, ce qui va nous aider à comprendre pourquoi le test a échoué ; paradoxalement, la macro assert! indique seulement qu'elle a obtenu une valeur false de l'expression avec le ==, mais n'affiche pas les valeurs qui l'ont mené à la valeur false.

Dans l'encart 11-7, nous écrivons une fonction ajouter_deux qui ajoute 2 à son paramètre et retourne le résultat. Ensuite, nous testons cette fonction en utilisant la macro assert_eq!.

Fichier : src/lib.rs

pub fn ajouter_deux(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cela_ajoute_deux() {
        assert_eq!(4, ajouter_deux(2));
    }
}

Encart 11-7 : test de la fonction ajouter_deux en utilisant la macro assert_eq!.

Vérifions si cela fonctionne !

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::cela_ajoute_deux ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Le premier argument que nous avons donné à la macro assert_eq!, 4, est bien égal au résultat de l'appel à ajouter_deux. La ligne correspondant à ce test est test tests::cela_ajoute_deux ... ok, et le texte ok indique que notre test a réussi !

Ajoutons un bogue dans notre code pour voir ce qu'il se passe lorsque un test qui utilise assert_eq! échoue. Changez l'implémentation de la fonction ajouter_deux pour ajouter plutôt 3 :

pub fn ajouter_deux(a: i32) -> i32 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cela_ajoute_deux() {
        assert_eq!(4, ajouter_deux(2));
    }
}

Lancez à nouveau les tests :

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::cela_ajoute_deux ... FAILED

failures:

---- tests::cela_ajoute_deux stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::cela_ajoute_deux

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'

Notre test a détecté le bogue ! Le test cela_ajoute_deux a échoué, ce qui a affiché le message assertion failed: `(left == right)` qui nous explique qu'à gauche nous avions 4 et qu'à droite nous avions 5. Ce message utile nous aide au déboguage : cela veut dire que l'argument de gauche de assert_eq! valait 4 mais que l'argument de droite, où nous avions ajouter_deux(2), valait 5.

Notez que dans certains langages et environnements de test, les paramètres des fonctions qui vérifient que deux valeurs soient égales sont appelés attendu et effectif, et l'ordre dans lesquels nous renseignons les arguments est important. Cependant, dans Rust, on les appelle gauche et droite, et l'ordre dans lesquels nous renseignons la valeur que nous attendons et la valeur que produit le code à tester n'est pas important. Nous pouvons écrire la vérification de ce test dans la forme assert_eq!(ajouter_deux(2), 4), ce qui donnera un message d'échec qui affichera assertion failed: `(left == right)` et que gauche vaudra 5 et droit vaudra 4.

La macro assert_ne! va réussir si les deux valeurs que nous lui donnons ne sont pas égales et va échouer si elles sont égales. Cette macro est utile dans les cas où nous ne sommes pas sûr de ce que devrait valoir une valeur, mais que nous savons ce que la valeur ne devrait surtout pas être si notre code fonctionne comme nous le souhaitons. Par exemple, si nous testons une fonction qui doit transformer sa valeur d'entrée de manière à ce qu'elle dépend du jour de la semaine où nous lançons nos tests, la meilleure façon de vérifier serait que la sortie de la fonction ne soit pas égale à son entrée.

Sous la surface, les macros assert_eq! et assert_ne! utilisent respectivement les opérateurs == et !=. Lorsque les vérifications échouent, ces macros affichent leurs arguments en utilisant le formatage de déboguage, ce qui veut dire que les valeurs comparées doivent implémenter les traits PartialEq et Debug. Tous les types primitifs et la plupart des types de la bibliothèque standard implémentent ces traits. Concernant les structures et les énumérations que vous définissez, vous allez avoir besoin de leur implémenter Debug pour afficher les valeurs lorsque les vérifications échouent. Comme ces traits sont des traits dérivables, comme nous l'avons évoqué dans l'encart 5-12 du chapitre 5, il suffit généralement de simplement ajouter l'annotation #[derive(PartialEq, Debug)] sur les définitions de vos structures ou énumérations. Rendez-vous à l'annexe C pour en savoir plus sur ces derniers et les autres traits dérivables.

Ajouter des messages d'échec personnalisés

Vous pouvez aussi ajouter un message personnalisé qui peut être affiché avec le message d'échec comme un argument optionnel aux macros assert!, assert_eq!, et assert_ne!. Tous les arguments renseignés après celui qui est obligatoire dans assert! ou les deux arguments obligatoires de assert_eq! et assert_ne! sont envoyés à la macro format! (que nous avons vue dans une section du chapitre 8), ainsi vous pouvez passer une chaine de caractères de formatage qui contient des espaces réservés {} et les valeurs iront dans ces espaces réservés. Les messages personnalisés sont utiles pour documenter ce que fait une vérification ; lorsqu'un test échoue, vous aurez une idée plus précise du problème avec ce code.

Par exemple, disons que nous avons une fonction qui accueille les gens par leur nom et que nous voulons tester que le nom que nous envoyons à la fonction apparaît dans le résultat :

Fichier : src/lib.rs

pub fn accueil(nom: &str) -> String {
    format!("Salut, {} !", nom)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accueil_contient_le_nom() {
        let resultat = accueil("Carole");
        assert!(resultat.contains("Carole"));
    }
}

Les spécifications de ce programme n'ont pas été validées entièrement pour le moment, et on est quasiment sûr que le texte Salut au début va changer. Nous avons décidé que nous ne devrions pas à avoir à changer le test si les spécifications changent, donc plutôt que de vérifier l'égalité exacte de la valeur retournée par la fonction accueil, nous allons uniquement vérifier que le résultat contient le texte correspondant au paramètre d'entrée de la fonction.

Introduisons un bogue dans ce code en changeant accueil pour ne pas ajouter nom afin de voir ce que donne l'échec de ce test :

pub fn accueil(name: &str) -> String {
    String::from("Salut !")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accueil_contient_le_nom() {
        let resultat = accueil("Carole");
        assert!(resultat.contains("Carole"));
    }
}

L'exécution du test va donner ceci :

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::accueil_contient_le_nom ... FAILED

failures:

---- tests::accueil_contient_le_nom stdout ----
thread 'main' panicked at 'assertion failed: resultat.contains(\"Carole\")', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::accueil_contient_le_nom

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'

Ce résultat indique simplement que la vérification a échoué, et à quel endroit. Le message d'échec serait plus utile dans notre cas s'il affichait la valeur que nous obtenons de la fonction accueil. Changeons la fonction de test, pour lui donner un message d'erreur personnalisé, qui est une chaîne de caractères de formatage avec un espace réservé qui contiendra la valeur que nous avons obtenue de la fonction accueil :

pub fn accueil(nom: &str) -> String {
    String::from("Salut !")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accueil_contient_le_nom() {
        let resultat = accueil("Carole");
        assert!(
            resultat.contains("Carole"),
            "Le message d'accueil ne contient pas le nom, il vaut `{}`",
            resultat
        );
    }
}

Maintenant, lorsque nous lançons à nouveau le test, nous obtenons un message d'échec plus explicite :

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::accueil_contient_le_nom ... FAILED

failures:

---- tests::accueil_contient_le_nom stdout ----
thread 'main' panicked at 'Le message d'accueil ne contient pas le nom, il vaut `Salut !`', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::accueil_contient_le_nom

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'

Nous pouvons voir la valeur que nous avons obtenue lors de la lecture du résultat du test, ce qui va nous aider à déboguer ce qui s'est passé à la place de ce que nous voulions qu'il se passe.

Vérifier le fonctionnement des paniques avec should_panic

En plus de vérifier que notre code retourne bien les valeurs que nous souhaitons, il est aussi important de vérifier que notre code gère bien les cas d'erreurs comme nous le souhaitons. Par exemple, utilisons le type Supposition que nous avons créé au chapitre 9, dans l'encart 9-13. Les autres codes qui utilisent Supposition reposent sur la garantie que les instances de Supposition contiennent uniquement des valeurs entre 1 et 100. Nous pouvons écrire un test qui s'assure que la création d'une instance de Supposition avec une valeur en dehors de cette intervalle va faire paniquer le programme.

Nous allons vérifier cela en ajoutant un autre attribut, should_panic, à notre fonction de test. Cet attribut fait réussir le test si le code à l'intérieur de la fonction fait paniquer ; le test va échouer si le code à l'intérieur de la fonction ne panique pas.

L'encart 11-8 nous montre un test qui vérifie que les conditions d'erreur de Supposition::new fonctionne bien comme nous l'avons prévu.

Fichier : src/lib.rs

pub struct Supposition {
    valeur: i32,
}

impl Supposition {
    pub fn new(valeur: i32) -> Supposition {
        if valeur < 1 || valeur > 100 {
            panic!("La supposition doit se trouver entre 1 et 100, et nous avons {}.", valeur);
        }

        Supposition { valeur }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn plus_grand_que_100() {
        Supposition::new(200);
    }
}

Encart 11-8 : tester qu'une condition va faire un panic

Nous plaçons l'attribut #[should_panic] après l'attribut #[test] et avant la fonction de test sur laquelle il s'applique. Voyons le résultat lorsque ce test réussit :

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::plus_grand_que_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Ca fonctionne ! Maintenant, ajoutons un bogue dans notre code en enlevant la condition dans laquelle la fonction new panique lorsque la valeur est plus grande que 100 :

pub struct Supposition {
    valeur: i32,
}

// -- partie masquée ici --
impl Supposition {
    pub fn new(valeur: i32) -> Supposition {
        if valeur < 1 {
            panic!("La supposition doit se trouver entre 1 et 100, et nous avons {}.", valeur);
        }

        Supposition { valeur }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn plus_grand_que_100() {
        Supposition::new(200);
    }
}

Lorsque nous lançons le test de l'encart 11-8, il va échouer :

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::plus_grand_que_100 - should panic ... FAILED

failures:

---- tests::plus_grand_que_100 stdout ----
note: test did not panic as expected

failures:
    tests::plus_grand_que_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'

Dans ce cas, nous n'obtenons pas de message très utile, mais lorsque nous regardons la fonction de test, nous constatons qu'elle est marquée avec #[should_panic]. L'échec que nous obtenons signifie que le code dans la fonction de test n'a pas fait paniquer.

Les tests qui utilisent should_panic ne sont parfois pas assez explicites car ils indiquent seulement que le code a paniqué. Un test should_panic peut réussir, même si le test panique pour une raison différente de celle que nous attendions. Pour rendre les tests should_panic plus précis, nous pouvons ajouter un paramètre optionnel expected à l'attribut should_panic. Le système de test va s'assurer que le message d'échec contient bien le texte renseigné. Par exemple, imaginons le code modifié de Supposition dans l'encart 11-9 où la fonction new panique avec des messages différents si la valeur est trop petite ou trop grande.

Fichier : src/lib.rs

pub struct Supposition {
    valeur: i32,
}

// -- partie masquée ici --
impl Supposition {
    pub fn new(valeur: i32) -> Supposition {
        if valeur < 1 {
            panic!(
                "La supposition doit être plus grande ou égale à 1, et nous avons {}.",
                valeur
            );
        } else if valeur > 100 {
            panic!(
                "La supposition doit être plus petite ou égale à 100, et nous avons {}.",
                valeur
            );
        }

        Supposition { valeur }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "La supposition doit être plus petite ou égale à 100")]
    fn plus_grand_que_100() {
        Supposition::new(200);
    }
}

Encart 11-9 : on vérifie qu'une situation va provoquer un panic! avec un message de panique bien précis

Ce test va réussir car la valeur que nous insérons dans l'attribut expected de should_panic est une partie du message de panique de la fonction Supposition::new. Nous aurions pu renseigner le message de panique en entier que nous attendions, qui dans ce cas est La supposition doit être plus petite ou égale à 100, et nous avons 200.. Ce que vous choisissez de renseigner dans le paramètre expected de should_panic dépend de la mesure dans laquelle le message de panique est unique ou dynamique et de la précision de votre test que vous souhaitez appliquer. Dans ce cas, un extrait du message de panique est suffisant pour s'assurer que le code de la fonction de test s'exécute dans le cas du else if valeur > 100.

Pour voir ce qui se passe lorsqu'un test should_panic qui a un message expected qui échoue, essayons à nouveau d'introduire un bogue dans notre code en permutant les corps des blocs de if valeur < 1 et de else if valeur > 100 :

pub struct Supposition {
    valeur: i32,
}

impl Supposition {
    pub fn new(valeur: i32) -> Supposition {
        if valeur < 1 {
            panic!(
                "La supposition doit être plus petite ou égale à 100, et nous avons {}.",
                valeur
            );
        } else if valeur > 100 {
            panic!(
                "La supposition doit être plus grande ou égale à 1, et nous avons {}.",
                valeur
            );
        }

        Supposition { valeur }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "La supposition doit être plus petite ou égale à 100")]
    fn plus_grand_que_100() {
        Supposition::new(200);
    }
}

Cette fois, lorsque nous lançons le test avec should_panic, il devrait échouer :

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::plus_grand_que_100 - should panic ... FAILED

failures:

---- tests::plus_grand_que_100 stdout ----
thread 'main' panicked at 'La supposition doit être plus grande ou égale à 1, et nous avons 200.', src/lib.rs:13:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"La supposition doit être plus grande ou égale à 1, et nous avons 200."`,
 expected substring: `"La supposition doit être plus petite ou égale à 100"`

failures:
    tests::plus_grand_que_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'

Le message d'échec nous informe que ce test a paniqué comme prévu, mais que le message de panique n'inclus pas la chaîne de caractères prévue 'La supposition doit être plus petite ou égale à 100'. Le message de panique que nous avons obtenu dans ce cas était La supposition doit être plus grande ou égale à 1, et nous avons 200.. Maintenant, on comprend mieux où est le bogue !

Utiliser Result<T, E> dans les tests

Précédemment, nous avons écrit des tests qui paniquent lorsqu'ils échouent. Nous pouvons également écrire des tests qui utilisent Result<T, E> ! Voici le test de l'encart 11-1, réécrit pour utiliser Result<T, E> et retourner une Err au lieu de paniquer :

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("deux plus deux ne vaut pas quatre"))
        }
    }
}

La fonction it_works a maintenant un type de retour, Result<(), String>. Dans le corps de la fonction, plutôt que d'appeler la macro assert_eq!, nous retournons Ok(()) lorsque le test réussit et une Err avec une String à l'intérieur lorsque le test échoue.

Ecrire vos tests afin qu'ils retournent un Result<T, E> vous permet d'utiliser l'opérateur point d'interrogation dans le corps des tests, ce qui est un outil facile à utiliser pour écrire des tests qui peuvent échouer si n'importe quelle opération en son sein retourne une variante de Err.

Vous ne pouvez pas utiliser l'annotation #[should_panic] sur les tests qui utilisent Result<T, E>. Pour vérifier qu'une opération retourne une variante Err, n'utilisez pas l'opérateur "point d'interrogation" sur la valeur de type Result<T, E>. A la place, utilisez plutôt assert!(valeur.is_err()).

Maintenant que vous avez appris différentes manières d'écrire des tests, voyons ce qui se passe lorsque nous lançons nos tests et explorons les différentes options que nous pouvons utiliser avec cargo test.