Aller au contenu

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