Aller au contenu

Les tests en Rust

En Rust, les tests sont intégrés directement au langage et à cargo. Pas besoin d’installer une bibliothèque à part pour commencer. Cette page vous donne juste ce qu’il faut pour écrire et lancer vos premiers tests.

Un test est une fonction ordinaire marquée avec #[test] :

fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}

Quelques points à remarquer :

  • #[cfg(test)] indique à Rust que ce module n’est compilé que lorsqu’on lance les tests. Il ne fait pas partie de votre programme normal.
  • use super::*; importe tout ce que contient le module parent (ici, la fonction add), pour que le test puisse l’appeler.
  • #[test] marque une fonction précise comme un test que cargo test doit exécuter.

Depuis le dossier de votre projet :

Terminal window
cargo test

Vous verrez une sortie de ce genre :

running 1 test
test tests::it_adds_two_numbers ... ok
test result: ok. 1 passed; 0 failed; 0 ignored

Cargo écrit en anglais : running 1 test signifie « exécution d’un test », ok que le test a réussi, et la dernière ligne fait le bilan (1 passed : 1 réussi, 0 failed : 0 échoué, 0 ignored : 0 ignoré).

Trois macros couvrent l’essentiel de vos besoins :

#[test]
fn checks() {
assert!(2 + 2 == 4); // fait échouer le test si la condition est fausse
assert_eq!(2 + 2, 4); // échoue si les deux valeurs sont différentes
assert_ne!(2 + 2, 5); // échoue si les deux valeurs SONT égales
}

On préfère en général assert_eq! et assert_ne! à un simple assert!(a == b). Quand elles échouent, elles affichent les deux valeurs : vous voyez tout de suite ce qui ne correspond pas, au lieu d’un simple « assertion failed » (l’assertion a échoué).

fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_will_fail() {
assert_eq!(add(2, 2), 5);
}
}
test tests::this_will_fail ... FAILED
assertion `left == right` failed
left: 4
right: 5

C’est exactement le genre de message qu’on veut voir quand un test échoue. left (gauche) est la valeur que votre code a réellement produite, et right (droite) celle que le test attendait.

Parfois, le bon comportement, c’est un panic (arrêt brutal du programme) : par exemple, une fonction qui doit refuser une entrée invalide. #[should_panic] sert à tester ce cas :

fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("impossible de diviser par zéro");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn dividing_by_zero_panics() {
divide(10, 0);
}
}

Quand un test échoue, vous voulez savoir pourquoi le plus vite possible. Deux petits ajouts aident beaucoup.

Un message personnalisé. assert!, assert_eq! et assert_ne! acceptent toutes un message supplémentaire après les valeurs, écrit comme dans un println!. Il ne s’affiche que si la vérification échoue.

expected sur should_panic. Un #[should_panic] tout seul réussit pour n’importe quel panic, même s’il vient d’un autre bug. En ajoutant expected = "...", le test ne réussit que si le message du panic contient ce texte.

Voici de nouveau la fonction divide, avec un main pour pouvoir la lancer, et deux tests qui utilisent ces deux idées :

fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("impossible de diviser par zéro");
}
a / b
}
fn main() {
println!("10 / 2 = {}", divide(10, 2));
println!("7 / 2 = {}", divide(7, 2));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_down() {
let result = divide(7, 2);
assert_eq!(result, 3, "7 / 2 devrait être arrondi vers le bas, mais on obtient {result}");
}
#[test]
#[should_panic(expected = "par zéro")]
fn dividing_by_zero_panics() {
divide(10, 0);
}
}

Sortie :

10 / 2 = 5
7 / 2 = 3
▶ Essayez par vous-même

La sortie ci-dessus est ce qu’affiche cargo run, car main ne lance pas les tests. Pour lancer les tests, utilisez cargo test. Dans le Rust Playground, ouvrez le menu à côté de Run et choisissez Test. Vous verrez :

running 2 tests
test tests::rounds_down ... ok
test tests::dividing_by_zero_panics - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Les tests s’exécutent en parallèle : les deux lignes test ... peuvent donc apparaître dans n’importe quel ordre. Imaginez maintenant que le test attende 4 au lieu de 3. L’échec afficherait votre message, juste à côté des deux valeurs :

assertion `left == right` failed: 7 / 2 devrait être arrondi vers le bas, mais on obtient 3
left: 3
right: 4

Et si divide déclenchait un panic avec un autre message, la vérification expected le repérerait :

note: panic did not contain expected string
panic message: "impossible de diviser par zéro"
expected substring: "négatif"

Rust vous dit ici que le message du panic (panic message) ne contenait pas le texte attendu (expected substring).

Pour être honnête : pour un petit script, vous ne le ferez sans doute pas. Les tests deviennent rentables dès que le code va être réutilisé ou modifié plus tard. Ils vous permettent de remanier votre code en toute confiance, car cargo test vous signale immédiatement si vous avez cassé quelque chose, au lieu que ce soit un utilisateur qui le découvre.

Les tests peuvent aussi vivre dans votre documentation : les exemples de code placés dans les commentaires de documentation /// sont eux aussi exécutés par cargo test, si bien que vos exemples ne peuvent jamais devenir obsolètes. La page Documentation en Rust vous montre comment faire.

Pour les deux exercices, la solution contient un main que vous pouvez lancer, et des tests que vous lancez avec cargo test (ou Test dans le Playground).

Écrivez fn grade(score: u32) -> char, qui renvoie 'A' à partir de 90, 'B' de 80 à 89, 'C' de 70 à 79, et 'F' en dessous. Écrivez ensuite des tests qui vérifient les limites, comme 90 et 89. Indice : les bugs se cachent aux limites. Un test avec exactement 90 repère l’erreur classique qui consiste à écrire > au lieu de >=.

Voir la solution
fn grade(score: u32) -> char {
if score >= 90 {
'A'
} else if score >= 80 {
'B'
} else if score >= 70 {
'C'
} else {
'F'
}
}
fn main() {
for score in [95, 85, 72, 40] {
println!("{score} -> {}", grade(score));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edges_of_a() {
assert_eq!(grade(90), 'A');
assert_eq!(grade(89), 'B');
}
#[test]
fn edges_of_c() {
assert_eq!(grade(70), 'C');
assert_eq!(grade(69), 'F');
}
#[test]
fn zero_is_f() {
assert_eq!(grade(0), 'F', "une note de 0 devrait être un échec");
}
}

Sortie :

95 -> A
85 -> B
72 -> C
40 -> F
▶ Essayez par vous-même

cargo test doit indiquer 3 passed (3 réussis). Essayez de remplacer score >= 90 par score > 90 et relancez les tests : edges_of_a échoue et vous montre left: 'B' et right: 'A'.

Écrivez fn safe_divide(a: i32, b: i32) -> Option<i32>, qui renvoie None quand b vaut 0 au lieu de déclencher un panic. Écrivez un test pour une division normale et un autre pour la division par zéro. Indice : assert_eq! fonctionne aussi avec Option, vous pouvez donc écrire assert_eq!(safe_divide(1, 0), None).

Voir la solution
fn safe_divide(a: i32, b: i32) -> Option<i32> {
if b == 0 {
None
} else {
Some(a / b)
}
}
fn main() {
println!("{:?}", safe_divide(10, 2));
println!("{:?}", safe_divide(1, 0));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn divides_normally() {
assert_eq!(safe_divide(10, 2), Some(5));
}
#[test]
fn zero_gives_none() {
assert_eq!(safe_divide(1, 0), None);
}
}

Sortie :

Some(5)
None
▶ Essayez par vous-même

Comparez avec la fonction divide plus haut. Là-bas, diviser par zéro provoque un panic, donc le test a besoin de #[should_panic]. Ici, c’est juste une valeur de retour normale : un simple assert_eq! suffit. Les fonctions qui renvoient une Option ou un Result sont en général plus faciles à tester.

  • Marquez une fonction avec #[test] pour en faire un test ; lancez tous les tests avec cargo test.
  • assert!, assert_eq! et assert_ne! couvrent la plupart des vérifications courantes. Préférez assert_eq!/assert_ne! pour des messages d’échec plus clairs.
  • #[should_panic] vérifie qu’une fonction déclenche bien un panic quand elle le doit. Ajoutez expected = "..." pour vérifier qu’elle l’a fait pour la bonne raison.
  • Ajoutez un message personnalisé à n’importe quelle macro d’assertion pour expliquer ce qui s’est mal passé en cas d’échec.
  • Les tests sont surtout précieux pour le code que vous comptez modifier ou réutiliser plus tard.

Petit quiz

1. Quel attribut marque une fonction comme un test que cargo test doit exécuter ?

2. Pourquoi préfère-t-on en général assert_eq! et assert_ne! à un simple assert!(a == b) ?

3. À quoi sert #[should_panic] ?

Score : 0 / 3