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 :
- Initialiser toutes les données ou les états,
- Lancer le code que vous voulez tester,
- 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);
}
}
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
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");
}
}
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'
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
}
}
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));
}
}
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));
}
}
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);
}
}
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);
}
}
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
.