L'organisation des tests

Comme nous l'avons évoqué au début du chapitre, le test est une discipline complexe, et différentes personnes utilisent des terminologies et organisations différentes. La communauté Rust a conçu les tests dans deux catégories principales : les tests unitaires et les tests d'intégration. Les tests unitaires sont petits et plus précis, testent un module isolé à la fois, et peuvent tester les interfaces privées. Les tests d'intégration sont uniquement externes à notre bibliothèque et consomment notre code exactement de la même manière que tout autre code externe le ferait, en utilisant uniquement l'interface publique et éventuellement en utilisant plusieurs modules dans un test.

L'écriture de ces deux types de tests est importante pour s'assurer que chaque élément de notre bibliothèque fait bien ce que vous attendiez d'eux, de manière isolée et conjuguée avec d'autres.

Les tests unitaires

Le but des tests unitaires est de tester chaque élément du code de manière séparée du reste du code pour identifier rapidement où le code fonctionne ou non comme prévu. Vous devriez insérer les tests unitaires dans le dossier src dans chaque fichier, à côté du code qu'ils testent. La convention est de créer un module tests dans chaque fichier qui contient les fonctions de test et de marquer le module avec cfg(test).

Les modules de tests et #[cfg(test)]

L'annotation #[cfg(test)] sur les modules de tests indique à Rust de compiler et d'exécuter le code de test seulement lorsque vous lancez cargo test, et non pas lorsque vous lancez cargo build. Cela diminue la durée de compilation lorsque vous souhaitez uniquement compiler la bibliothèque et cela réduit la taille dans l'artefact compilé qui en résulte car les tests n'y sont pas intégrés. Vous verrez plus tard que comme les tests d'intégration se placent dans un répertoire différent, ils n'ont pas besoin de l'annotation #[cfg(test)]. Cependant, comme les tests unitaires vont dans les mêmes fichiers que le code, vous devriez utiliser #[cfg(test)] pour marquer qu'ils ne devraient pas être inclus dans les résultats de compilation.

Souvenez-vous, lorsque nous avons généré le nouveau projet addition dans la première section de ce chapitre, Cargo a généré ce code pour nous :

Fichier : src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let resultat = 2 + 2;
        assert_eq!(resultat, 4);
    }
}

Ce code est le module de test généré automatiquement. L'attribut cfg est l'abréviation de configuration et indique à Rust que l'élément suivant ne doit être intégré que lorsqu'une certaine option de configuration est donnée. Dans ce cas, l'option de configuration est test, qui est fournie par Rust pour la compilation et l'exécution des tests. En utilisant l'attribut cfg, Cargo compile notre code de tests uniquement si nous avons exécuté les tests avec cargo test. Cela inclut toutes les fonctions auxiliaires qui pourraient se trouver dans ce module, en plus des fonctions marquées d'un #[test].

Tester des fonctions privées

Il existe un débat dans la communauté des testeurs au sujet de la nécessité ou non de tester directement les fonctions privées, et d'autres langages rendent difficile, voir impossible, de tester les fonctions privées. Quelle que soit votre approche des tests, les règles de protection de Rust vous permettent de tester des fonctions privées. Imaginons le code de l'encart 11-12 qui contient la fonction privée addition_interne.

Fichier : src/lib.rs

pub fn ajouter_deux(a: i32) -> i32 {
    addition_interne(a, 2)
}

fn addition_interne(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn interne() {
        assert_eq!(4, addition_interne(2, 2));
    }
}

Encart 11-12 : test d'une fonction privée

Remarquez que la fonction addition_interne n'est pas marquée comme pub. Les tests sont uniquement du code Rust, et le module test est simplement un autre module. Comme nous l'avons vu dans la section "Désigner un élément dans l'arborescence de modules", les éléments dans les modules enfants peuvent utiliser les éléments dans leurs modules parents. Dans ce test, nous importons dans la portée tous les éléments du parent du module test grâce à use super::*;, permettant ensuite au test de faire appel à addition_interne. Si vous pensez qu'une fonction privée ne doit pas être testée, il n'y a rien qui vous y force avec Rust.

Les tests d'intégration

En Rust, les tests d'intégration sont exclusivement externes à votre bibliothèque. Ils consomment votre bibliothèque de la même manière que n'importe quel autre code, ce qui signifie qu'ils ne peuvent appeler que les fonctions qui font partie de l'interface de programmation applicative (API) publique de votre bibliothèque. Leur but est de tester si les multiples parties de votre bibliothèque fonctionnent correctement ensemble. Les portions de code qui fonctionnent bien toutes seules pourraient rencontrer des problèmes une fois imbriquées avec d'autres, donc les tests qui couvrent l'intégration du code sont tout aussi importants. Pour créer des tests d'intégration, vous avez d'abord besoin d'un dossier tests.

Le dossier tests

Nous créons un dossier tests au niveau le plus haut de notre dossier projet, juste à côté de src. Cargo sait qu'il doit rechercher les fichiers de test d'intégration dans ce dossier. Nous pouvons ensuite construire autant de fichiers de test que nous le souhaitons dans ce dossier, et Cargo va compiler chacun de ces fichiers comme une crate individuelle.

Commençons à créer un test d'intégration. Avec le code de l'encart 11-12 toujours présent dans le fichier src/lib.rs, créez un dossier tests, puis un nouveau fichier tests/test_integration.rs et insérez-y le code de l'encart 11-13.

Fichier : tests/test_integration.rs

use addition;

#[test]
fn cela_ajoute_deux() {
    assert_eq!(4, addition::ajouter_deux(2));
}

Encart 11-13 : un test d'intégration d'une fonction présente dans la crate addition

Nous avons ajouté use addition en haut du code, ce que nous n'avions pas besoin de faire dans les tests unitaires. La raison à cela est que chaque fichier dans le dossier tests est une crate séparée, donc nous devons importer notre bibliothèque dans la portée de chaque crate de test.

Nous n'avons pas besoin de marquer du code avec #[cfg(test)] dans tests/test_integration.rs. Cargo traite le dossier tests de manière particulière et compile les fichiers présents dans ce dossier uniquement si nous lançons cargo test. Lancez dès maintenant cargo test :

$ cargo test
   Compiling addition v0.1.0 (file:///projects/addition)
    Finished test [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests (target/debug/deps/addition-1082c4b063a8fbe6)

running 1 test
test tests::interne ... ok

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

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test cela_ajoute_deux ... ok

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

   Doc-tests addition

running 0 tests

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

Les trois sections de la sortie concernent les tests unitaires, les tests d'intégration et les tests de documentation. La première section relative aux tests unitaires est la même que celle que nous avons déjà vue : une ligne pour chaque test unitaire (celui qui s'appelle interne que nous avons inséré dans l'encart 11-12) suivie d'une ligne de résumé des tests unitaires.

La section des tests d'intégration commence avec la ligne Running target/debug/deps/test_integration-1082c4b063a8fbe6 (le hachage à la fin de votre sortie pourrait être différent). Ensuite, il y a une ligne pour chaque fonction de test présente dans ce test d'intégration et une ligne de résumé pour les résultats des tests d'intégration, juste avant que la section Doc-tests addition ne commence.

De la même façon que plus vous ajoutiez de fonctions de tests unitaires et plus vous aviez de lignes de résultats dans la section des tests unitaires, plus vous ajoutez des fonctions de tests aux fichiers de tests d'intégration et plus vous obtenez de lignes de résultat dans la section correspondant aux fichiers des tests d'intégration. Chaque fichier de test d'intégration a sa propre section, donc si nous ajoutons plus de fichiers dans le dossier tests, il y aura plus de sections de tests d'intégration.

Nous pouvons aussi exécuter une fonction de test d'intégration précise en utilisant le nom de la fonction de test comme argument à cargo test. Pour exécuter tous les tests d'un fichier de tests d'intégration précis, utilisez l'argument --test de cargo test suivi du nom du fichier :

$ cargo test --test integration_test
   Compiling addition v0.1.0 (file:///projects/addition)
    Finished test [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test cela_ajoute_deux ... ok

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

Cette commande exécute seulement les tests dans le fichier tests/test_integration.rs.

Les sous-modules des tests d'intégration

Au fur et à mesure que vous ajouterez des tests d'intégration, vous pourriez avoir besoin de les diviser en plusieurs fichiers dans le dossier tests pour vous aider à les organiser ; par exemple, vous pouvez regrouper les fonctions de test par fonctionnalités qu'elles testent. Comme mentionné précédemment, chaque fichier dans le dossier tests est compilé comme étant sa propre crate séparée de tous les autres.

Le fait que chaque fichier de test d'intégration soit sa propre crate est utile pour créer des portées séparées qui ressemblent à la manière dont les développeurs vont consommer votre crate. Cependant, cela veut aussi dire que les fichiers dans le dossier tests ne partagent pas le même comportement que les les fichiers dans src, comme vous l'avez appris au chapitre 7 à propos de la manière de séparer le code dans des modules et des fichiers.

Ce comportement différent des fichiers dans le dossier tests est encore plus notable lorsque vous avez un jeu de fonctions d'aide qui s'avèrent utiles pour plusieurs fichiers de test d'intégration et que vous essayez de suivre les étapes de la section “Séparer les modules dans différents fichiers” du chapitre 7 afin de les extraire dans un module en commun. Par exemple, si nous créons tests/commun.rs et que nous y plaçons une fonction parametrage à l'intérieur, nous pourrions ajouter du code à parametrage que nous voudrions appeler à partir de différentes fonctions de test dans différents fichiers de test :

Fichier : tests/commun.rs


#![allow(unused)]
fn main() {
pub fn parametrage() {
    // code de paramétrage spécifique à vos tests de votre bibliothèque ici
}
}

Lorsque nous lançons les tests à nouveau, nous allons voir une nouvelle section dans la sortie des tests, correspondant au fichier commun.rs, même si ce fichier ne contient aucune fonction de test et que nous n'avons utilisé nulle part la fonction parametrage :

$ cargo test
   Compiling addition v0.1.0 (file:///projects/addition)
    Finished test [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests (target/debug/deps/addition-92948b65e88960b4)

running 1 test
test tests::interne ... ok

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

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

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

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test cela_ajoute_deux ... ok

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

   Doc-tests addition

running 0 tests

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

Nous ne voulons pas que commun apparaisse dans les résultats, ni que cela affiche running 0 tests. Nous voulons juste partager du code avec les autres fichiers de test d'intégration.

Pour éviter que commun s'affiche sur la sortie de test, au lieu de créer le fichier tests/commun.rs, nous allons créer tests/commun/mod.rs. C'est une convention de nommage alternative que Rust comprend aussi. Nommer le fichier ainsi indique à Rust de ne pas traiter le module commun comme un fichier de test d'intégration. Lorsque nous déplaçons le code de la fonction parametrage dans tests/commun/mod.rs et que nous supprimons le fichier tests/commun.rs, la section dans la sortie des tests ne va plus s'afficher. Les fichiers dans les sous-répertoires du dossier tests ne seront pas compilés comme étant une crate séparée et n'auront pas de sections dans la sortie des tests.

Après avoir créé tests/commun/mod.rs, nous pouvons l'utiliser à partir de n'importe quel fichier de test d'intégration comme un module. Voici un exemple d'appel à la fonction parametrage à partir du test cela_ajoute_deux dans tests/test_integration.rs :

Fichier : tests/integration_test.rs

use addition;

mod common;

#[test]
fn cela_ajoute_deux() {
    common::parametrage();
    assert_eq!(4, addition::ajouter_deux(2));
}

Remarquez que la déclaration mod commun; est la même que la déclaration d'un module que nous avons montrée dans l'encart 7-21. Ensuite, dans la fonction de tests, nous pouvons appeler la fonction commun::parametrage.

Tests d'intégration pour les crates binaires

Si notre projet est une crate binaire qui contient uniquement un fichier src/main.rs et n'a pas de fichier src/lib.rs, nous ne pouvons pas créer de tests d'intégration dans le dossier tests et importer les fonctions définies dans le fichier src/main.rs dans notre portée avec une instruction use. Seules les crates de bibliothèque exposent des fonctions que les autres crates peuvent utiliser ; les crates binaires sont conçues pour être exécutées de manière isolée.

C'est une des raisons pour lesquelles les projets Rust qui fournissent un binaire ont un simple fichier src/main.rs qui fait appel à la logique présente dans le fichier src/lib.rs. En utilisant cette structure, les tests d'intégration peuvent tester la crate de bibliothèque avec le use pour importer les importantes fonctionnalités disponibles. Si les fonctionnalités importantes fonctionnent, la petite portion de code dans le fichier src/main.rs va fonctionner, et cette petite partie de code n'a pas besoin d'être testée.

Résumé

Les fonctionnalités de test de Rust permettent de spécifier comment le code doit fonctionner pour garantir qu'il va continuer à fonctionner comme vous le souhaitez, même si vous faites des changements. Les tests unitaires permettent de tester séparément différentes parties d'une bibliothèque et peuvent tester l'implémentation des éléments privés. Les tests d'intégration vérifient que de nombreuses parties de la bibliothèque fonctionnent correctement ensemble, et ils utilisent l'API publique de la bibliothèque pour tester le code, de la même manière que le ferait du code externe qui l'utiliserait. Même si le système de type de Rust et les règles de possession aident à empêcher certains types de bogues, les tests restent toujours importants pour réduire les bogues de logique concernant le comportement attendu de votre code.

Et maintenant, combinons le savoir que vous avez accumulé dans ce chapitre et dans les chapitres précédents en travaillant sur un nouveau projet !