Patterns de test Rust
Patterns complets de test Rust pour écrire des tests fiables et maintenables selon la méthodologie TDD.
Quand l'utiliser
- Lors de l'écriture de nouvelles fonctions, méthodes ou traits Rust
- Pour ajouter de la couverture de test à du code existant
- Pour créer des benchmarks sur du code critique en performance
- Pour implémenter des tests basés sur les propriétés (validation d'entrée)
- Pour suivre un workflow TDD dans un projet Rust
Fonctionnement
- Identifier le code cible — Trouver la fonction, le trait ou le module à tester
- Écrire un test — Utiliser
#[test]dans un module#[cfg(test)],rstestpour les tests paramétrés, ouproptestpour les tests basés sur les propriétés - Simuler les dépendances — Utiliser
mockallpour isoler l'unité testée - Exécuter les tests (ROUGE) — Vérifier que le test échoue avec l'erreur attendue
- Implémenter (VERT) — Écrire le code minimal pour réussir
- Refactoriser — Améliorer tout en gardant les tests au vert
- Vérifier la couverture — Utiliser
cargo-llvm-cov, viser 80%+
Workflow TDD pour Rust
Le cycle ROUGE-VERT-REFACTOR
ROUGE → Écrire d'abord un test qui échoue
VERT → Écrire le code minimal pour passer le test
REFACTOR → Améliorer le code tout en gardant les tests au vert
RÉPÉTER → Continuer avec l'exigence suivante
TDD étape par étape en Rust
// ROUGE : Écrire le test d'abord, utiliser todo!() comme placeholder
pub fn add(a: i32, b: i32) -> i32 { todo!() }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() { assert_eq!(add(2, 3), 5); }
}
// cargo test → panique à 'not yet implemented'
// VERT : Remplacer todo!() par l'implémentation minimale
pub fn add(a: i32, b: i32) -> i32 { a + b }
// cargo test → PASS, puis REFACTOR en gardant les tests au vert
Tests unitaires
Organisation des tests au niveau module
// src/user.rs
pub struct User {
pub name: String,
pub email: String,
}
impl User {
pub fn new(name: impl Into<String>, email: impl Into<String>) -> Result<Self, String> {
let email = email.into();
if !email.contains('@') {
return Err(format!("email invalide : {email}"));
}
Ok(Self { name: name.into(), email })
}
pub fn display_name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cree_un_utilisateur_avec_email_valide() {
let user = User::new("Alice", "[email protected]").unwrap();
assert_eq!(user.display_name(), "Alice");
assert_eq!(user.email, "[email protected]");
}
#[test]
fn rejette_un_email_invalide() {
let result = User::new("Bob", "pas-un-email");
assert!(result.is_err());
assert!(result.unwrap_err().contains("email invalide"));
}
}
Macros d'assertion
assert_eq!(2 + 2, 4); // Égalité
assert_ne!(2 + 2, 5); // Inégalité
assert!(vec![1, 2, 3].contains(&2)); // Booléen
assert_eq!(value, 42, "attendait 42 mais obtenu {value}"); // Message personnalisé
assert!((0.1_f64 + 0.2 - 0.3).abs() < f64::EPSILON); // Comparaison flottante
Tests d'erreur et de panique
Tester les retours Result
#[test]
fn parse_retourne_une_erreur_pour_entree_invalide() {
let result = parse_config("}{"invalide");
assert!(result.is_err());
// Vérifier une variante d'erreur spécifique
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::ParseError(_)));
}
#[test]
fn parse_reussit_pour_entree_valide() -> Result<(), Box<dyn std::error::Error>> {
let config = parse_config(r#"{"port": 8080}"#)?;
assert_eq!(config.port, 8080);
Ok(()) // Le test échoue si un ? retourne Err
}
Tester les paniques
#[test]
#[should_panic]
fn panique_sur_entree_vide() {
process(&[]);
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn panique_avec_message_specifique() {
let v: Vec<i32> = vec![];
let _ = v[0];
}
Tests d'intégration
Structure de fichiers
ma_crate/
├── src/
│ └── lib.rs
├── tests/ # Tests d'intégration
│ ├── api_test.rs # Chaque fichier est un binaire de test séparé
│ ├── db_test.rs
│ └── common/ # Utilitaires de test partagés
│ └── mod.rs
Écrire des tests d'intégration
// tests/api_test.rs
use ma_crate::{App, Config};
#[test]
fn cycle_de_vie_complet_dune_requete() {
let config = Config::test_default();
let app = App::new(config);
let response = app.handle_request("/health");
assert_eq!(response.status, 200);
assert_eq!(response.body, "OK");
}
Tests asynchrones
Avec Tokio
#[tokio::test]
async fn recupere_les_donnees_avec_succes() {
let client = TestClient::new().await;
let result = client.get("/data").await;
assert!(result.is_ok());
assert_eq!(result.unwrap().items.len(), 3);
}
#[tokio::test]
async fn gere_le_timeout() {
use std::time::Duration;
let result = tokio::time::timeout(
Duration::from_millis(100),
operation_lente(),
).await;
assert!(result.is_err(), "aurait dû expirer");
}
Patterns d'organisation des tests
Tests paramétrés avec rstest
use rstest::{rstest, fixture};
#[rstest]
#[case("hello", 5)]
#[case("", 0)]
#[case("rust", 4)]
fn test_longueur_chaine(#[case] input: &str, #[case] expected: usize) {
assert_eq!(input.len(), expected);
}
// Fixtures
#[fixture]
fn test_db() -> TestDb {
TestDb::new_in_memory()
}
#[rstest]
fn test_insertion(test_db: TestDb) {
test_db.insert("clé", "valeur");
assert_eq!(test_db.get("clé"), Some("valeur".into()));
}
Helpers de test
#[cfg(test)]
mod tests {
use super::*;
/// Crée un utilisateur de test avec des valeurs par défaut raisonnables.
fn creer_utilisateur(nom: &str) -> User {
User::new(nom, &format!("{nom}@test.com")).unwrap()
}
#[test]
fn affichage_utilisateur() {
let user = creer_utilisateur("alice");
assert_eq!(user.display_name(), "alice");
}
}
Tests basés sur les propriétés avec proptest
Tests de propriétés basiques
use proptest::prelude::*;
proptest! {
#[test]
fn aller_retour_encodage_decodage(input in ".*") {
let encoded = encode(&input);
let decoded = decode(&encoded).unwrap();
assert_eq!(input, decoded);
}
#[test]
fn tri_conserve_la_longueur(mut vec in prop::collection::vec(any::<i32>(), 0..100)) {
let original_len = vec.len();
vec.sort();
assert_eq!(vec.len(), original_len);
}
#[test]
fn tri_produit_un_ensemble_ordonne(mut vec in prop::collection::vec(any::<i32>(), 0..100)) {
vec.sort();
for window in vec.windows(2) {
assert!(window[0] <= window[1]);
}
}
}
Stratégies personnalisées
use proptest::prelude::*;
fn email_valide() -> impl Strategy<Value = String> {
("[a-z]{1,10}", "[a-z]{1,5}")
.prop_map(|(user, domain)| format!("{user}@{domain}.com"))
}
proptest! {
#[test]
fn accepte_les_emails_valides(email in email_valide()) {
assert!(User::new("Test", &email).is_ok());
}
}
Mocking avec mockall
Mocking basé sur les traits
use mockall::{automock, predicate::eq};
#[automock]
trait UserRepository {
fn find_by_id(&self, id: u64) -> Option<User>;
fn save(&self, user: &User) -> Result<(), StorageError>;
}
#[test]
fn service_retourne_utilisateur_quand_trouve() {
let mut mock = MockUserRepository::new();
mock.expect_find_by_id()
.with(eq(42))
.times(1)
.returning(|_| Some(User { id: 42, name: "Alice".into() }));
let service = UserService::new(Box::new(mock));
let user = service.get_user(42).unwrap();
assert_eq!(user.name, "Alice");
}
#[test]
fn service_retourne_none_quand_non_trouve() {
let mut mock = MockUserRepository::new();
mock.expect_find_by_id()
.returning(|_| None);
let service = UserService::new(Box::new(mock));
assert!(service.get_user(99).is_none());
}
Tests de documentation (Doc Tests)
Documentation exécutable
/// Ajoute deux nombres.
///
/// # Exemples
///
/// ```
/// use ma_crate::add;
///
/// assert_eq!(add(2, 3), 5);
/// assert_eq!(add(-1, 1), 0);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Analyse une chaîne de configuration.
///
/// # Erreurs
///
/// Retourne `Err` si l'entrée n'est pas du TOML valide.
///
/// ```no_run
/// use ma_crate::parse_config;
///
/// let config = parse_config(r#"port = 8080"#).unwrap();
/// assert_eq!(config.port, 8080);
/// ```
///
/// ```no_run
/// use ma_crate::parse_config;
///
/// assert!(parse_config("}{"invalide").is_err());
/// ```
pub fn parse_config(input: &str) -> Result<Config, ParseError> {
todo!()
}
Benchmarking avec Criterion
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "benchmark"
harness = false
// benches/benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn fibonacci(n: u64) -> u64 {
match n {
0 | 1 => n,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn bench_fibonacci(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
Couverture de test
Exécuter la couverture
# Installation : cargo install cargo-llvm-cov (ou utiliser taiki-e/install-action dans CI)
cargo llvm-cov # Résumé
cargo llvm-cov --html # Rapport HTML
cargo llvm-cov --lcov > lcov.info # Format LCOV pour CI
cargo llvm-cov --fail-under-lines 80 # Échec si en dessous du seuil
Cibles de couverture
| Type de code | Cible |
|---|---|
| Logique métier critique | 100% |
| API publique | 90%+ |
| Code général | 80%+ |
| Code généré / liaisons FFI | Exclure |
Commandes de test
cargo test # Exécuter tous les tests
cargo test -- --nocapture # Afficher la sortie println
cargo test nom_du_test # Exécuter les tests correspondant au pattern
cargo test --lib # Tests unitaires uniquement
cargo test --test api_test # Tests d'intégration uniquement
cargo test --doc # Tests de documentation uniquement
cargo test --no-fail-fast # Ne pas s'arrêter à la première erreur
cargo test -- --ignored # Exécuter les tests ignorés
Bonnes pratiques
À FAIRE :
- Écrire les tests EN PREMIER (TDD)
- Utiliser les modules
#[cfg(test)]pour les tests unitaires - Tester le comportement, pas l'implémentation
- Utiliser des noms de tests descriptifs qui expliquent le scénario
- Préférer
assert_eq!àassert!pour de meilleurs messages d'erreur - Utiliser
?dans les tests qui retournentResultpour une sortie d'erreur plus propre - Garder les tests indépendants — pas d'état mutable partagé
À NE PAS FAIRE :
- Utiliser
#[should_panic]quand on peut testerResult::is_err()à la place - Mock tout — préférer les tests d'intégration quand c'est possible
- Ignorer les tests instables — les corriger ou les mettre en quarantaine
- Utiliser
sleep()dans les tests — utiliser des canaux, des barrières outokio::time::pause() - Sauter les tests de chemins d'erreur
Intégration CI
# GitHub Actions
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Vérifier le formatage
run: cargo fmt --check
- name: Clippy
run: cargo clippy -- -D warnings
- name: Exécuter les tests
run: cargo test
- uses: taiki-e/install-action@cargo-llvm-cov
- name: Couverture
run: cargo llvm-cov --fail-under-lines 80
Souvenez-vous : Les tests sont de la documentation. Ils montrent comment votre code est censé être utilisé. Écrivez-les clairement et tenez-les à jour.