backend / rust
Tests
Explication
Ce que vous allez apprendre
- Écrire un test unitaire avec
#[test], exécuté parcargo test - Isoler les tests du reste du code avec
#[cfg(test)] mod tests - Utiliser
assert!,assert_eq!et#[should_panic]pour vérifier un comportement - Écrire un test table-driven, l'équivalent Rust du pattern déjà vu en Go
- Distinguer tests unitaires, tests d'intégration et doctests
Dans quel contexte ?
Un développeur modifie sa fonction est_premier pour corriger un cas limite sur le nombre 1, mais casse accidentellement le traitement du nombre 4 sans s'en apercevoir immédiatement. Sans suite de tests automatisés, ce bug ne serait découvert qu'en production, potentiellement des semaines plus tard. cargo test détecte cette régression en une fraction de seconde, avant même le commit.
D'abord, Rust intègre son propre framework de test dans cargo
Une fonction annotée #[test] devient un test exécutable par cargo test, sans configuration ni dépendance externe à ajouter. La convention veut que ces tests soient placés dans un sous-module mod tests, marqué #[cfg(test)] pour n'être compilé que lors de l'exécution des tests, jamais dans le binaire de production final.
Une fois un test écrit, plusieurs macros vérifient le comportement attendu
assert!(condition) échoue si la condition est fausse. assert_eq!(a, b) échoue si a diffère de b, en affichant automatiquement les deux valeurs comparées dans le message d'erreur — bien plus lisible qu'un simple assert!(a == b). #[should_panic(expected = "message")] vérifie qu'une fonction panique bien avec le message attendu, utile pour tester volontairement un cas d'échec extrême.
| Macro/attribut | Vérifie | Message en cas d'échec |
|---|---|---|
assert!(cond) | Une condition booléenne | Générique, sans détail des valeurs |
assert_eq!(a, b) | Égalité entre deux valeurs | Affiche a et b côte à côte |
#[should_panic] | Une panique attendue | Échoue si le code NE panique PAS |
Prérequis
Il faut être à l'aise avec les fonctions, Result et la structure de module use super::* (leçons précédentes) : un module de tests importe généralement tout son module parent d'un coup.
Il reste un pattern directement transposé d'un autre langage de ce cours : le table-driven test
Comme en Go, définir un vecteur de tuples (entrée, résultat attendu) puis boucler dessus avec assert_eq! dans chaque itération permet de couvrir de nombreux cas sans dupliquer une fonction de test pour chacun. C'est un pattern universel dès qu'un langage propose des tests unitaires natifs et des structures de données simples.
Piège fréquent
Un test qui retourne Result<(), E> (au lieu de rien) permet d'utiliser l'opérateur ? directement dans le corps du test, ce qui simplifie l'écriture — mais un test ainsi écrit doit impérativement se terminer par Ok(()) en cas de succès, sinon cargo test le considère en échec même si toutes les assertions ont réussi.
Enfin, trois niveaux de tests coexistent dans un projet Rust complet
Les tests unitaires (#[cfg(test)] mod tests) vivent au plus près du code testé. Les tests d'intégration (dossier tests/ à la racine) sont compilés comme un crate externe et ne peuvent tester que l'API publique. Les doctests (des exemples de code dans les commentaires de documentation ///) sont réellement exécutés par cargo test, garantissant que la documentation ne devient jamais obsolète par rapport au comportement réel du code.
Bonne pratique
Écris toujours un exemple de code dans les commentaires de documentation (///) des fonctions publiques importantes : ces doctests servent à la fois de documentation vivante ET de test automatisé, une combinaison unique à l'écosystème Rust.
Maintenant que tu sais vérifier la correction de ton code, la prochaine leçon change de registre pour aborder un mécanisme de génération de code à la compilation : les macros.
Commandes & code
Tests
Rust intègre son propre framework de test, exécuté par cargo test.
// fichier: src/lib.rs
pub fn diviser(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("division par zero"))
} else {
Ok(a / b)
}
}
pub fn est_premier(n: u32) -> bool {
if n < 2 { return false; }
for i in 2..=((n as f64).sqrt() as u32) {
if n % i == 0 { return false; }
}
true
}
// Module de tests conventionnellement place dans le meme fichier, sous cfg(test)
#[cfg(test)] // ce module n'est compile QUE lors de `cargo test`
mod tests {
use super::*; // importe tout le module parent (les fonctions testees)
#[test]
fn test_diviser_ok() {
let resultat = diviser(10.0, 2.0);
assert_eq!(resultat, Ok(5.0));
}
#[test]
fn test_diviser_par_zero() {
let resultat = diviser(10.0, 0.0);
assert!(resultat.is_err());
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_panique_attendue() {
let v: Vec<i32> = vec![1, 2, 3];
let _ = v[10]; // doit paniquer avec ce message
}
// Table-driven test, idiome equivalent au pattern Go
#[test]
fn test_est_premier() {
let cas = vec![
(0, false), (1, false), (2, true),
(4, false), (17, true), (100, false),
];
for (entree, attendu) in cas {
assert_eq!(est_premier(entree), attendu, "echec pour n={}", entree);
}
}
// Test qui retourne Result : permet d'utiliser ? dans le test lui-meme
#[test]
fn test_avec_resultat() -> Result<(), String> {
let v = diviser(10.0, 5.0)?;
assert_eq!(v, 2.0);
Ok(())
}
}
// Tests d'integration : dossier tests/ a la racine, compile comme un crate externe
// fichier: tests/integration_test.rs
// use mon_projet::diviser;
// #[test]
// fn test_api_publique() {
// assert_eq!(diviser(4.0, 2.0), Ok(2.0));
// }
// Doctests : exemples dans la doc, EXECUTES par cargo test
/// Additionne deux nombres.
///
/// # Examples
/// ```
/// assert_eq!(mon_projet::additionner(2, 3), 5);
/// ```
pub fn additionner(a: i32, b: i32) -> i32 { a + b }cargo test # lance tous les tests (unitaires + integration + doctests)
cargo test test_est_premier # filtre par nom
cargo test -- --nocapture # affiche les println! meme pour les tests qui passent
cargo test -- --test-threads=1 # desactive le parallelisme (utile pour debug)Résumé
#[cfg(test)] mod testsisole les tests unitaires, compilés uniquement pourcargo test.assert!,assert_eq!,assert_ne!valident les résultats ;#[should_panic]vérifie une panique attendue.- Le dossier
tests/à la racine contient les tests d'intégration, exécutés contre l'API publique du crate. - Les exemples de documentation (
///avec un bloc de code) sont exécutés comme des tests (doctests).
Exercices pratiques
Mission : une regression silencieuse sur le calcul de remise
Objectif : Ecrire une suite de tests table-driven qui detecte une regression volontaire, et diagnostiquer un test Result mal termine.
Contexte
Une fonction calculer_remise(montant: f64, fidele: bool) -> f64 applique 10% de remise si le client est fidele, 0% sinon, mais ne doit jamais retourner un montant negatif meme sur une entree invalide. Un dev a modifie la fonction pour ajouter une remise supplementaire sur les gros montants, et pense n'avoir rien casse car 'ca compile'. Aucun test n'existe encore sur cette fonction. Separement, un collegue a ecrit un test qui retourne Result<(), String> et se plaint qu'il echoue a cargo test alors que 'toutes les assertions passent visiblement'.