Tests en Rust¶
Rust intégré le testing directement dans le compilateur et le gestionnaire de paquets Cargo : aucun framework externe n'est nécessaire pour écrire des tests unitaires et d'intégration. L'écosystème complementaire (proptest, criterion, insta, mockall) couvre les besoins avances de tests generatifs, de benchmarks et de tests par snapshots.
Tests intégrés — syntaxe de base¶
Les tests unitaires en Rust vivent directement dans le fichier source, dans un module annote #[cfg(test)]. Ce module est exclu de la compilation release.
// src/calcul.rs
/// Additionne deux entiers — deborde en mode debug, wrapping en release
pub fn additionner(a: i32, b: i32) -> i32 {
a + b
}
/// Divise a par b — retourne None si b vaut zero
pub fn diviser(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
// Module de tests — compile uniquement en mode test
#[cfg(test)]
mod tests {
use super::*; // Importe les elements du module parent
#[test]
fn test_additionner_positifs() {
assert_eq!(additionner(2, 3), 5);
}
#[test]
fn test_additionner_negatifs() {
assert_eq!(additionner(-1, -1), -2);
}
#[test]
fn test_diviser_normal() {
let resultat = diviser(10.0, 4.0).expect("Division valide attendue");
assert!((resultat - 2.5).abs() < f64::EPSILON);
}
#[test]
fn test_diviser_par_zero() {
assert_eq!(diviser(5.0, 0.0), None);
}
// Test qui doit paniquer — verifie qu'une panique est levee
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_panique_attendue() {
let v = vec![1, 2, 3];
let _ = v[10]; // Doit paniquer
}
}
# Lancer tous les tests
cargo test
# Lancer les tests d'un module specifique
cargo test calcul::tests
# Afficher les println! meme pour les tests qui passent
cargo test -- --nocapture
# Lancer les tests en parallele limite (1 thread = sequentiel)
cargo test -- --test-threads=1
Tests d'intégration¶
Les tests d'intégration vivent dans le répertoire tests/ à la racine du crate. Ils testent l'API publique comme un consommateur externe.
// tests/integration_calcul.rs
// Ce fichier est un crate de test independant — il ne voit que l'API publique
use api_crud::calcul::{additionner, diviser};
#[test]
fn test_integration_addition_chaine() {
// Verifie une chaine d'operations
let a = additionner(10, 20);
let b = additionner(a, 5);
assert_eq!(b, 35);
}
#[test]
fn test_integration_division_precise() {
let r = diviser(1.0, 3.0).unwrap();
assert!((r - 0.3333).abs() < 0.0001);
}
Tests sur l'API Axum avec tower::ServiceExt¶
Les handlers Axum peuvent être testés sans lancer un vrai serveur HTTP, en utilisant tower::ServiceExt::oneshot.
// tests/test_api.rs
use axum::{
body::Body,
http::{Request, StatusCode},
};
use serde_json::{json, Value};
use tower::ServiceExt; // Pour .oneshot()
use http_body_util::BodyExt; // Pour .collect()
// Fonction helper — construit l'application de test avec une BDD en memoire
async fn app_de_test() -> axum::Router {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
// Insere un item de test
sqlx::query!("INSERT INTO items (nom, valeur) VALUES ('test', 9.99)")
.execute(&pool)
.await
.unwrap();
crate::construire_app(pool)
}
#[tokio::test]
async fn test_lister_items_200() {
let app = app_de_test().await;
let reponse = app
.oneshot(
Request::builder()
.uri("/items")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(reponse.status(), StatusCode::OK);
// Deserialization du corps de reponse
let corps = reponse.into_body().collect().await.unwrap().to_bytes();
let items: Value = serde_json::from_slice(&corps).unwrap();
assert!(items.as_array().unwrap().len() >= 1);
}
#[tokio::test]
async fn test_obtenir_item_404() {
let app = app_de_test().await;
let reponse = app
.oneshot(
Request::builder()
.uri("/items/9999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(reponse.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_creer_item_validation() {
let app = app_de_test().await;
// Envoi d'un payload avec nom vide — doit retourner 400
let payload = json!({ "nom": "", "valeur": 5.0 });
let reponse = app
.oneshot(
Request::builder()
.method("POST")
.uri("/items")
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(reponse.status(), StatusCode::BAD_REQUEST);
}
Mocking avec traits et mockall¶
Rust n'a pas de mocking "magique" comme Mockito en Java. Le pattern standard est d'abstraire les dépendances derriere un trait, puis d'utiliser mockall pour générer des mocks automatiquement.
// src/service.rs
use async_trait::async_trait;
/// Trait abstrayant le stockage — permet le mocking en test
#[async_trait]
pub trait StockageItem: Send + Sync {
async fn obtenir(&self, id: i64) -> Option<String>;
async fn sauvegarder(&self, nom: String) -> i64;
}
/// Service metier qui depend du trait, pas de l'implementation concrete
pub struct ServiceItem<S: StockageItem> {
stockage: S,
}
impl<S: StockageItem> ServiceItem<S> {
pub fn nouveau(stockage: S) -> Self {
Self { stockage }
}
pub async fn obtenir_ou_defaut(&self, id: i64) -> String {
self.stockage
.obtenir(id)
.await
.unwrap_or_else(|| "valeur par defaut".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::mock;
// mockall genere une struct MockStockageItem implementant le trait
mock! {
pub StockageItemMock {}
#[async_trait]
impl StockageItem for StockageItemMock {
async fn obtenir(&self, id: i64) -> Option<String>;
async fn sauvegarder(&self, nom: String) -> i64;
}
}
#[tokio::test]
async fn test_obtenir_existant() {
let mut mock = MockStockageItemMock::new();
// Definit l'attente : obtenir(1) sera appele une fois et retournera Some("test")
mock.expect_obtenir()
.with(mockall::predicate::eq(1_i64))
.times(1)
.returning(|_| Some("test".into()));
let service = ServiceItem::nouveau(mock);
let resultat = service.obtenir_ou_defaut(1).await;
assert_eq!(resultat, "test");
}
#[tokio::test]
async fn test_obtenir_absent_retourne_defaut() {
let mut mock = MockStockageItemMock::new();
mock.expect_obtenir()
.returning(|_| None);
let service = ServiceItem::nouveau(mock);
let resultat = service.obtenir_ou_defaut(42).await;
assert_eq!(resultat, "valeur par defaut");
}
}
Tests generatifs avec proptest¶
Proptest généré automatiquement des valeurs de test pour trouver des cas limites que les tests manuels ratent.
// Cargo.toml — [dev-dependencies]
// proptest = "1"
#[cfg(test)]
mod tests_proptest {
use proptest::prelude::*;
use crate::calcul::diviser;
proptest! {
// Teste que diviser ne panique jamais pour n'importe quels f64 non NaN
#[test]
fn test_diviser_ne_panique_jamais(a in -1000.0f64..1000.0, b in -1000.0f64..1000.0) {
let _ = diviser(a, b); // Ne doit pas paniquer
}
// Teste la propriete de commutativite de l'addition
#[test]
fn test_addition_commutative(a in -100i32..100, b in -100i32..100) {
use crate::calcul::additionner;
assert_eq!(additionner(a, b), additionner(b, a));
}
}
}
Benchmarks avec criterion¶
Criterion fournit des benchmarks statistiquement rigoureux avec détection de régression.
// benches/bench_parser.rs
// Cargo.toml — [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] }
// Cargo.toml — [[bench]] name = "bench_parser" harness = false
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use log_parser::parser_ligne;
fn bench_parser_ligne(c: &mut Criterion) {
let ligne = r#"127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326"#;
c.bench_function("parser_ligne", |b| {
// black_box empeche le compilateur d'optimiser le benchmark
b.iter(|| parser_ligne(black_box(ligne)))
});
}
fn bench_parser_fichier(c: &mut Criterion) {
// 1000 lignes identiques pour le benchmark
let contenu: String = std::iter::repeat(
"127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] \"GET /index.html HTTP/1.0\" 200 1234\n"
).take(1000).collect();
c.bench_function("parser_1000_lignes", |b| {
b.iter(|| log_parser::parser_fichier(black_box(&contenu)))
});
}
criterion_group!(benches, bench_parser_ligne, bench_parser_fichier);
criterion_main!(benches);
# Lancer les benchmarks
cargo bench
# Generer un rapport HTML dans target/criterion/
# Detecter les regressions par rapport a la baseline
cargo bench -- --save-baseline main
Tests par snapshots avec insta¶
Insta compare la sortie d'une fonction avec une snapshot sauvegardee. Lors du premier run, la snapshot est créée. Les runs suivants verifient qu'elle n'a pas change.
// Cargo.toml — [dev-dependencies] insta = { version = "1", features = ["json"] }
#[cfg(test)]
mod tests_snapshots {
use insta::assert_json_snapshot;
use crate::parser_ligne;
#[test]
fn test_snapshot_entree_log() {
let ligne = r#"192.168.1.1 - - [15/Jan/2024:10:30:00 +0100] "POST /api/data HTTP/1.1" 201 512"#;
let entree = parser_ligne(ligne).unwrap();
// La snapshot est sauvegardee dans tests/snapshots/
assert_json_snapshot!(entree);
}
}
# Premiere execution — cree la snapshot
cargo test
# Mettre a jour les snapshots apres un changement intentionnel
cargo insta review # Interface interactive
cargo insta accept # Accepter toutes les modifications
Couverture de code¶
| Outil | Méthode | Points forts |
|---|---|---|
| cargo-tarpaulin | Instrumentation | Simple, Linux uniquement, rapport HTML/Coveralls |
| cargo-llvm-cov | LLVM coverage | Cross-platform, rapide, format LCOV pour CI |
# Installation
cargo install cargo-tarpaulin
cargo install cargo-llvm-cov
# cargo-tarpaulin — rapport HTML
cargo tarpaulin --out Html --output-dir coverage/
# cargo-llvm-cov — rapport HTML + LCOV
cargo llvm-cov --html
cargo llvm-cov --lcov --output-path lcov.info
# Integrer dans GitHub Actions
cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
Couverture et unsafe
Les blocs unsafe sont comptabilises dans la couverture mais ne sont pas vérifiés par le borrow checker. Une couverture de 100% sur du code unsafe ne garantit pas l'absence de comportement indefini. Pour vérifier le code unsafe, utiliser Miri (voir chapitre Écosystème).
Comparatif des outils de test¶
| Outil | Type de test | Cas d'usage |
|---|---|---|
#[test] | Unitaire / intégré | Tests standards, inclus dans le compilateur |
| proptest | Generatif | Trouver des cas limites automatiquement |
| criterion | Benchmark | Mesures de performance reproductibles |
| insta | Snapshot | Stabilité des sorties texte / JSON complexes |
| mockall | Mocking | Isolation des dépendances via traits |
| cargo-tarpaulin | Couverture (Linux) | CI simple sur Linux / GitHub Actions |
| cargo-llvm-cov | Couverture | Cross-platform, format LCOV standard |