Contactez-nous

Ecrire des tests unitaires simples

Apprenez les bases de l'écriture de tests unitaires en Rust : l'annotation #[test], les macros d'assertion (assert!, assert_eq!) et l'exécution avec `cargo test`.

Les tests unitaires en Rust : une fondation pour la fiabilité du code

L'écriture de tests est une pratique essentielle en développement logiciel pour garantir la qualité, la fiabilité et la maintenabilité du code. Rust intègre nativement un framework de test léger mais puissant, qui facilite grandement la création de tests unitaires. Les tests unitaires se concentrent sur la vérification de petites portions de code, typiquement des fonctions ou des méthodes, de manière isolée. Leur objectif est de s'assurer que chaque "unité" de votre programme se comporte comme attendu dans divers scénarios.

En Rust, les tests unitaires sont souvent placés directement dans les mêmes fichiers que le code qu'ils testent, au sein d'un module dédié généralement nommé `tests` et annoté avec `#[cfg(test)]`. Cette annotation indique au compilateur que le module ne doit être compilé et inclus que lors de l'exécution des tests (par exemple, avec `cargo test`), et non lors de la compilation normale du projet. Cette co-localisation du code et de ses tests facilite leur maintenance et leur découverte.

Adopter une démarche de test dès le début du développement permet de détecter les régressions rapidement, de faciliter le refactoring en toute confiance et de fournir une documentation vivante du comportement attendu de votre code. Ce chapitre vous guidera à travers les étapes fondamentales pour écrire vos premiers tests unitaires en Rust.

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

La pierre angulaire de l'écriture de tests en Rust est l'attribut `#[test]`. Placé au-dessus d'une fonction, il signale au framework de test que cette fonction est un cas de test individuel à exécuter. Une fonction de test est une fonction ordinaire qui ne prend généralement pas d'arguments et ne retourne rien (ou plus précisément, retourne le type unité `()`). Son rôle est d'exécuter une portion de code et de vérifier que le résultat est conforme aux attentes.

Voici la structure typique d'un module de tests au sein d'un fichier `src/lib.rs` ou `src/main.rs` :

// Votre code de production ici
fn additionner(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)] // Ce module n'est compilé que pour les tests
mod tests {
    use super::*; // Importe les éléments du module parent (comme `additionner`)

    #[test]
    fn test_addition_simple() {
        assert_eq!(additionner(2, 2), 4);
    }

    #[test]
    fn test_addition_negatifs() {
        assert_eq!(additionner(-2, -2), -4);
    }

    #[test]
    #[should_panic] // Ce test est censé provoquer une panique
    fn test_addition_overflow() {
        // Pour cet exemple, imaginons une fonction qui paniquerait en cas de dépassement
        // Dans la réalité, `i32` gère l'overflow par défaut (wrapping en mode release)
        // Pour réellement tester un panic, il faudrait une fonction qui appelle `panic!`
        // fn addition_panique_sur_overflow(a: i32, b: i32) -> i32 {
        //     match a.checked_add(b) {
        //         Some(v) => v,
        //         None => panic!("Dépassement d'entier!"),
        //     }
        // }
        // assert_eq!(addition_panique_sur_overflow(i32::MAX, 1), 0); // Ceci paniquerait
    }
}

Dans cet exemple, `mod tests` crée un module enfant. L'instruction `use super::*;` permet d'importer tous les items (fonctions, structs, etc.) du module parent (celui contenant la fonction `additionner`) dans la portée du module `tests`. Chaque fonction annotée avec `#[test]` (`test_addition_simple`, `test_addition_negatifs`) constitue un test distinct. L'attribut `#[should_panic]` sur `test_addition_overflow` indique que ce test est considéré comme réussi si le code qu'il contient provoque une panique (une erreur non récupérable).

L'organisation des tests en modules dédiés permet de séparer clairement le code de production de son code de test, tout en les maintenant proches pour faciliter la navigation et la compréhension. Pour des projets plus conséquents, il est aussi possible de placer des tests d'intégration dans un répertoire `tests` à la racine du projet, à côté du répertoire `src`. Ces tests sont traités comme des crates distinctes qui importent et utilisent la bibliothèque principale de votre projet.

Les macros d'assertion : `assert!`, `assert_eq!` et `assert_ne!`

Au coeur de chaque fonction de test se trouve la vérification que le comportement du code est celui attendu. En Rust, cette vérification s'effectue principalement à l'aide de macros d'assertion. Si une assertion échoue (c'est-à-dire si la condition qu'elle vérifie n'est pas remplie), le test est considéré comme ayant échoué, et le framework de test le signalera.

Les macros d'assertion les plus couramment utilisées sont :

  • `assert!(expression_booléenne)` : Cette macro vérifie si `expression_booléenne` s'évalue à `true`. Si ce n'est pas le cas, le test échoue et la macro panique. Vous pouvez optionnellement ajouter un message d'erreur personnalisé qui sera affiché si l'assertion échoue : `assert!(a > b, "a ({}) devrait être supérieur à b ({})", a, b);`
#[test]
fn test_avec_assert() {
    let resultat = 5;
    assert!(resultat > 0);
    assert!(resultat < 10, "Le résultat {} n'est pas inférieur à 10", resultat);
}
  • `assert_eq!(gauche, droite)` : Cette macro vérifie si les expressions `gauche` et `droite` sont égales (en utilisant l'opérateur `==`). Si elles ne le sont pas, le test échoue et la macro panique, affichant les valeurs de `gauche` et `droite` pour faciliter le débogage. C'est souvent la macro la plus utilisée car elle permet de comparer directement une valeur obtenue avec une valeur attendue. Un message d'erreur personnalisé peut également être ajouté.
#[test]
fn test_avec_assert_eq() {
    fn saluer(nom: &str) -> String {
        format!("Bonjour, {}!", nom)
    }
    assert_eq!(saluer("Monde"), String::from("Bonjour, Monde!"));
    // Exemple d'échec avec message personnalisé
    // assert_eq!(2 + 2, 5, "Addition incorrecte : 2+2 devrait être 4, pas {}", 2+2);
}
  • `assert_ne!(gauche, droite)` : Cette macro est l'inverse de `assert_eq!`. Elle vérifie si les expressions `gauche` et `droite` ne sont pas égales (en utilisant l'opérateur `!=`). Si elles sont égales, le test échoue et la macro panique. Un message d'erreur personnalisé est également possible.
#[test]
fn test_avec_assert_ne() {
    let x = 10;
    let y = 5;
    assert_ne!(x, y, "x ({}) ne devrait pas être égal à y ({})", x, y);
}

Ces macros sont fondamentales pour exprimer les conditions de succès de vos tests. Elles permettent de définir clairement ce que vous attendez de votre code et d'obtenir un retour précis en cas d'échec.

Lancer les tests avec `cargo test` et interpréter les résultats

Une fois que vous avez écrit vos tests unitaires, l'outil Cargo facilite grandement leur exécution. Pour lancer tous les tests de votre projet (y compris les tests unitaires dans `src` et les tests d'intégration dans le répertoire `tests`), il suffit d'exécuter la commande suivante à la racine de votre projet :

cargo test

Cargo va alors compiler votre code en mode test (en incluant les modules `#[cfg(test)]`) et exécuter chaque fonction annotée avec `#[test]`. Par défaut, les tests sont exécutés en parallèle pour accélérer le processus. La sortie de `cargo test` vous informera du nombre de tests exécutés, du nombre de tests réussis (`ok`), du nombre de tests échoués (`FAILED`), du nombre de tests ignorés (`ignored`) et du nombre de tests filtrés (`filtered out`).

Si tous les tests réussissent, vous verrez un message similaire à celui-ci :

running 3 tests
test tests::test_addition_negatifs ... ok
test tests::test_addition_simple ... ok
test tests::test_addition_overflow ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

En cas d'échec d'un test, `cargo test` affichera des informations détaillées sur l'échec, y compris le nom du test, le fichier et la ligne où l'assertion a échoué, et souvent les valeurs qui ont causé l'échec (particulièrement avec `assert_eq!` et `assert_ne!`). Par exemple, si `test_addition_simple` contenait `assert_eq!(additionner(2, 2), 5);`, la sortie ressemblerait à :

running 1 test
test tests::test_addition_simple ... FAILED

failures:

---- tests::test_addition_simple stdout ----
thread 'tests::test_addition_simple' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::test_addition_simple

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cette sortie détaillée est cruciale pour identifier rapidement la source du problème et le corriger.

Vous pouvez également exécuter des tests spécifiques en fournissant une partie du nom du test ou du module à `cargo test`. Par exemple, pour exécuter uniquement `test_addition_simple` :

cargo test test_addition_simple

Ou tous les tests du module `tests` (ce qui est souvent le cas par défaut si les tests sont dans un module nommé `tests`) :

cargo test tests::

Il est aussi possible d'ignorer certains tests en utilisant l'attribut `#[ignore]`. Ces tests ne seront pas exécutés par défaut, mais pourront l'être spécifiquement avec la commande `cargo test -- --ignored`. L'intégration des tests unitaires simples avec `cargo test` constitue une base solide pour assurer la qualité et la fiabilité de vos projets Rust, vous permettant de développer avec plus de confiance.