Contactez-nous

L'annotation `#[test]` et l'organisation des tests

Découvrez comment utiliser l'annotation `#[test]` en Rust pour définir des tests unitaires et comment les organiser efficacement dans vos modules pour une meilleure maintenabilité.

L'annotation `#[test]` : la porte d'entrée des tests en Rust

Au coeur du framework de test intégré de Rust se trouve un mécanisme simple mais puissant pour déclarer des fonctions comme étant des cas de test : l'attribut `#[test]`. Lorsqu'une fonction est précédée de cette annotation, le compilateur Rust et l'outil Cargo la reconnaissent comme une unité de test à exécuter lors de l'invocation de `cargo test`. C'est la brique de base pour construire votre suite de tests et vérifier le comportement de votre code.

Une fonction de test typique en Rust est une fonction publique ou privée (bien que souvent privée au module de test) qui ne prend aucun argument et ne retourne aucune valeur (implicitement, elle retourne le type unité `()`). Son rôle est d'exécuter une séquence d'opérations, généralement en appelant une partie du code de production, puis d'utiliser des macros d'assertion (comme `assert!`, `assert_eq!`) pour vérifier que les résultats obtenus sont conformes aux attentes. Si une assertion échoue à l'intérieur d'une fonction de test, la fonction panique, et le test est marqué comme ayant échoué.

Voici un exemple minimaliste d'une fonction de test :

fn est_pair(n: i32) -> bool {
    n % 2 == 0
}

#[cfg(test)]
mod tests_internes {
    use super::*; // Rend `est_pair` visible ici

    #[test]
    fn un_nombre_pair_est_reconnu() {
        assert!(est_pair(2));
    }

    #[test]
    fn un_nombre_impair_n_est_pas_pair() {
        assert!(!est_pair(3));
    }
}

Dans cet exemple, `un_nombre_pair_est_reconnu` et `un_nombre_impair_n_est_pas_pair` sont deux fonctions de test distinctes, chacune vérifiant un aspect de la fonction `est_pair`. L'annotation `#[test]` est ce qui les identifie comme telles auprès du harnais de test de Rust.

Organisation des tests unitaires : modules et co-localisation

Rust encourage une convention d'organisation des tests unitaires qui favorise la proximité entre le code testé et ses tests. La pratique la plus courante consiste à placer les tests unitaires pour un module donné directement dans le même fichier que ce module, mais encapsulés dans un sous-module enfant, typiquement nommé `tests`.

Ce sous-module de tests est généralement annoté avec `#[cfg(test)]`. L'attribut `cfg` signifie "configuration" et permet la compilation conditionnelle. `#[cfg(test)]` indique que le code contenu dans ce module (le module `tests` et toutes ses fonctions de test) ne doit être compilé que lorsque Rust construit le projet en mode test, c'est-à-dire lorsque vous exécutez `cargo test`. Lors d'une compilation normale pour la production (par exemple, avec `cargo build` ou `cargo build --release`), ce module de test et son contenu sont ignorés, n'alourdissant pas l'exécutable final.

Considérons un fichier `src/math_utils.rs` contenant quelques fonctions utilitaires :

// Contenu de src/math_utils.rs
pub fn addition(a: i32, b: i32) -> i32 {
    a + b
}

pub fn soustraction(a: i32, b: i32) -> i32 {
    a - b
}

// Module de tests pour math_utils
#[cfg(test)]
mod tests {
    // Importe toutes les fonctions du module parent (math_utils)
    use super::*;

    #[test]
    fn test_fonction_addition() {
        assert_eq!(addition(5, 3), 8, "5 + 3 devrait être égal à 8");
        assert_eq!(addition(-1, 1), 0);
    }

    #[test]
    fn test_fonction_soustraction() {
        assert_eq!(soustraction(10, 4), 6);
        assert_eq!(soustraction(3, 5), -2);
    }

    // On pourrait avoir d'autres fonctions de test ici
}

Dans cette structure, `mod tests { ... }` définit le module de test. La ligne `use super::*;` à l'intérieur du module `tests` est cruciale. Elle permet d'importer tous les items (fonctions, structs, enums, etc.) définis dans le module parent (ici, `math_utils`) dans la portée du module `tests`. Cela rend les fonctions `addition` et `soustraction` directement accessibles pour être testées.

Cette approche de co-localisation présente plusieurs avantages :

  • Proximité : Les tests sont proches du code qu'ils valident, ce qui facilite leur découverte et leur mise à jour lorsque le code de production évolue.
  • Accès aux éléments privés : Bien que les fonctions de test elles-mêmes soient souvent dans un module enfant, elles peuvent, si nécessaire et si elles sont dans le même fichier, accéder aux éléments privés du module parent (ce qui n'est pas le cas pour les tests d'intégration qui sont dans un crate séparé). Cependant, la bonne pratique générale est de tester l'interface publique d'un module.
  • Clarté : La séparation logique via `#[cfg(test)]` et `mod tests` maintient une distinction claire entre le code de production et le code de test.

Conventions de nommage et tests spécifiques

Bien qu'il n'y ait pas de règle stricte imposée par le compilateur pour le nommage des fonctions de test, une convention courante est de préfixer les noms de fonctions de test par `test_` ou d'utiliser des noms descriptifs qui expliquent clairement ce que le test vérifie. Par exemple, `test_addition_avec_nombres_positifs` est plus explicite que `test1`.

Il est parfois utile de tester des conditions qui devraient provoquer une panique (une fin abrupte du programme due à une erreur irrécupérable). L'attribut `#[should_panic]` peut être ajouté à une fonction `#[test]` pour indiquer que le test est considéré comme réussi si et seulement si le code à l'intérieur de la fonction de test panique. Vous pouvez même spécifier un message de panique attendu avec `#[should_panic(expected = "message attendu")]`.

pub struct ValeurPositive(i32);

impl ValeurPositive {
    pub fn new(valeur: i32) -> ValeurPositive {
        if valeur <= 0 {
            panic!("La valeur doit être strictement positive!");
        }
        ValeurPositive(valeur)
    }
}

#[cfg(test)]
mod panic_tests {
    use super::*;

    #[test]
    #[should_panic(expected = "La valeur doit être strictement positive!")]
    fn test_creation_valeur_negative_panique() {
        ValeurPositive::new(-5);
    }

    #[test]
    // #[should_panic] // Ce test échouerait car ValeurPositive::new(5) ne panique pas.
    fn test_creation_valeur_positive_ne_panique_pas() {
        ValeurPositive::new(5); // Pas de panique ici, donc le test passe.
    }
}

L'organisation de vos tests à l'aide de modules `#[cfg(test)]` et l'utilisation judicieuse de l'annotation `#[test]` (et `#[should_panic]` si besoin) constituent la base pour construire une suite de tests robuste et maintenable en Rust. Cela vous permet d'exécuter `cargo test` en toute confiance, sachant que seules les fonctions de test désignées seront exécutées pour valider votre code.