Tests

En Rust, il n’y a pas besoin d’installer un outil tiers pour tester son code, l’environnement de développement officiel du projet fournit tout ce qu’il faut pour les besoins courants. Toutefois, les outils tiers peuvent apporter des compléments appréciables comme le support du test basé sur les propriétés, et grâce à cargo ils sont très simples à utiliser.

Tests unitaires et exemples

Pour écrire des tests unitaires (fonctions par fonction), l’usage est de commencer par écrire à la fin d’un de ses modules de code le squelette suivant. Il assure que les tests ne seront compilés que pendant le développement, et écartés du binaire final, tout en vous gardant l’accès direct à tous les symboles du module parent via l’import global use super::*.

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    // ... vos tests vont ici ...
}
}

Un test unitaire est ensuite une simple fonction surmontée d’une macro #[test] qui génère le code d’initialisation et finalisation nécessaire :

#![allow(unused)]
fn main() {
#[test]
fn nom_du_test() {
    todo!();
}
}

Au sein d’un test, vous pouvez pour une fois vous lâcher sur l’utilisation des paniques, puisque l’objectif n’est pas d’écrire du code qui gère soigneusement ses erreurs avec possibilité de récupération et message clair pour l’utilisateur final en cas d’échec. On veut juste un programme qui crashe avec une backtrace si un problème est détecté, et les paniques sont très bien pour ça :

#![allow(unused)]
fn main() {
#[test]
fn test_egalite() {
    assert_eq!(42, 42);
}
}

Néanmoins, la gestion d’erreur via Result reste possible, donc vous pouvez écrire un test qui renvoie Result<(), E> avec E un type qui implémente le trait Error, et le test échouera en affichant la description de l’erreur si la variante Result::Err est émise par le test.

#![allow(unused)]
fn main() {
#[test]
fn test_ouverture() -> std::io::Result<()> {
    let mut fichier = File::open("/chemin/invalide.lol")?;
    writeln!(fichier, "Bonjour")?;
    Ok(())
}
}

Une fois vos tests écrits, vous pouvez les lancer avec cargo test. Cette commande exécutera l’ensemble de vos tests en parallèle sur plusieurs threads, et si vous avez des exemples dans votre documentation, elle vérifiera également qu’ils compilent et s’exécutent correctement. Un rapport sur l’exécution sera affiché ce faisant…

Cliquez ici pour afficher un exemple de rapport
running 23 tests
test bitmaps::tests::empty ... ok
test bitmaps::tests::full ... ok
test bitmaps::tests::empty_op_range ... ok
test bitmaps::tests::empty_op_index ... ok
test bitmaps::tests::from_range_op_range ... ok
test bitmaps::tests::full_op_range ... ok
test objects::types::tests::should_compare_object_types ... ok
test bitmaps::tests::from_range_op_index ... ok
test bitmaps::tests::from_range ... ok
test bitmaps::tests::empty_extend ... ok
test bitmaps::tests::from_iterator ... ok
test bitmaps::tests::full_extend ... ok
test bitmaps::tests::empty_op_bitmap ... ok
test bitmaps::tests::full_op_index ... ok
test bitmaps::tests::from_range_extend ... ok
test bitmaps::tests::from_range_op_bitmap ... ok
test bitmaps::tests::arbitrary_op_index ... ok
test bitmaps::tests::arbitrary_extend ... ok
test bitmaps::tests::arbitrary_op_bitmap ... ok
test bitmaps::tests::arbitrary_op_range ... ok
test bitmaps::tests::full_op_bitmap ... ok
test bitmaps::tests::arbitrary ... ok
test topology::support::tests::should_support_cpu_binding_on_linux ... ok

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

   Doc-tests hwlocality

running 54 tests
test src/bitmaps/indices.rs - bitmaps::indices::BitmapIndex::count_zeros (line 130) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::full (line 206) ... ok
test src/bitmaps/indices.rs - bitmaps::indices::BitmapIndex::count_ones (line 116) ... ok
test src/bitmaps/indices.rs - bitmaps::indices::BitmapIndex::from_str_radix (line 101) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::is_empty (line 559) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::intersects (line 764) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::fill (line 290) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::clear (line 274) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::invert (line 746) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::copy_from (line 254) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::is_full (line 578) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::includes (line 789) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::from_range (line 227) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::first_unset (line 686) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::first_set (line 599) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::new (line 186) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::iter_unset (line 708) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::last_set (line 639) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::last_unset (line 724) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::is_set (line 529) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::iter_set (line 621) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::weight (line 663) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set (line 364) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::singlify (line 510) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set_range (line 393) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set_all_but (line 335) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set_only (line 306) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap (line 64) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::unset_range (line 459) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::unset (line 430) ... ok
test src/cpu/caches.rs - cpu::caches::Topology::cpu_cache_stats (line 22) ... ok
test src/objects/mod.rs - objects::Topology::type_at_depth (line 340) ... ok
test src/objects/mod.rs - objects::Topology::depth_for_type (line 112) ... ok
test src/lib.rs - (line 72) ... ok
test src/objects/mod.rs - objects::Topology::depth_or_below_for_type (line 151) ... ok
test src/objects/mod.rs - objects::Topology::depth_for_cache (line 276) ... ok
test src/objects/mod.rs - objects::Topology::size_at_depth (line 370) ... ok
test src/objects/mod.rs - objects::Topology::objects_with_type (line 472) ... ok
test src/objects/mod.rs - objects::Topology::root_object (line 443) ... ok
test src/objects/mod.rs - objects::Topology::memory_parents_depth (line 78) ... ok
test src/objects/mod.rs - objects::Topology::depth_or_above_for_type (line 210) ... ok
test src/objects/mod.rs - objects::Topology::depth (line 51) ... ok
test src/objects/mod.rs - objects::Topology::objects_at_depth (line 392) ... ok
test src/topology/builder.rs - topology::builder::TopologyBuilder::build (line 66) ... ok
test src/topology/builder.rs - topology::builder::TopologyBuilder::with_flags (line 336) ... ok
test src/topology/builder.rs - topology::builder::TopologyBuilder::new (line 43) ... ok
test src/topology/mod.rs - topology::Topology::build_flags (line 191) ... ok
test src/topology/mod.rs - topology::Topology::is_abi_compatible (line 164) ... ok
test src/topology/mod.rs - topology::Topology::feature_support (line 238) ... ok
test src/topology/mod.rs - topology::Topology::new (line 107) ... ok
test src/topology/mod.rs - topology::Topology::supports (line 271) ... ok
test src/topology/mod.rs - topology::Topology::is_this_system (line 211) ... ok
test src/topology/mod.rs - topology::Topology::builder (line 140) ... ok
test src/topology/mod.rs - topology::Topology::type_filter (line 294) ... ok

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

…et en cas de problème avec un test, vous aurez un rapport d’erreur à la fin :

failures:

---- bitmaps::tests::empty stdout ----
thread 'bitmaps::tests::empty' panicked at 'assertion failed: `(left != right)`
  left: `0-`,
 right: `0-`', src/bitmaps/mod.rs:2027:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    bitmaps::tests::empty

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

Je vous recommande fortement d’écrire vos tests pour qu’ils s’exécutent très rapidement (moins d’une seconde) et que ça soit viable pour vous les lancer tous très régulièrement. Néanmoins, si vous ne parvenez pas à adopter cette bonne pratique, sachez qu’il est possible de ne lancer qu’un sous-ensemble de vos tests en passant une expression régulière de filtrage à cargo test :

cargo test -- '(abc|def).*ijk'

Si vous aimez déboguer en affichant les valeurs de variables sur stdout ou stderr, vous apprécierez aussi la macro dbg!() de la bibliothèque standard. Celle-ci prend en paramètre une valeur implémentant Debug et réémet cette valeur en sortie après l’avoir affichée ainsi que l’emplacement du code source où la macro dbg!() est située. Par exemple, ce genre de code…

#![allow(unused)]
fn main() {
fn generation() -> u32 { 42 }
fn traitement(_: u32) {}
traitement(dbg!(generation()));
}

…générera ce genre de sortie :

[src/main.rs:6] generation() = 42

Bien sûr, l’inconvénient de cette méthode de déboguage est qu’il ne faut pas oublier d’enlever toutes les utilisations de la macro dbg!() du code une fois que vous aurez trouvé le problème.

Tests basés sur les propriétés

Force est de constater que les développeurs écrivent peu de tests, et que quand ils en écrivent ils les écrivent mal avec des cas tests qui manquent trop de diversité. Les humains sont de très mauvais générateurs d’aléatoire, leurs choix tendent à suivre des tendances très prévisibles parce que c’est moins fatiguant pour le cerveau. Par exemple, avez vous remarqué combien de fois les nombres placeholder 42 et 123 apparaissent dans ce cours ?

Un professeur taquin avait ainsi tenté un jour de demander à une classe d’une quarantaine d’étudiants universitaires d’implémenter l’algorithme de recherche par dichotomie, et observé qu’à la fin aucun n’avait pensé spontanément à tester l’ensemble des cas tordus (tableau vide, gros tableau avec débordement d’entiers lors du calcul de l’indice moyen…), et donc aucune des implémentations produites n’était correcte pour l’ensemble des tableaux d’entrée possibles.

Il existe cependant une technique très puissante pour limiter l’ampleur de ce problème, que je vous encourage fortement à utiliser, c’est le test basé sur les propriétés (property-based testing). En Rust, deux bibliothèques en fournissent une implémentation :

  • quickcheck va droit à l’essentiel, est triviale à apprendre, et suffit souvent.
  • proptest offre un contrôle plus fin qui peut être utile dans certains cas.

Puisque ce cours est une introduction et ne vise pas à faire de vous des experts du test basé sur les propriétés, nous n’allons parler que de quickcheck.

Pour l’utiliser, on ajoute quickcheck comme dépendance de développement avec un simple cargo add, avec le complément quickcheck_macros qui rend l’utilisation encore plus facile…

cargo add --dev quickcheck quickcheck_macros

…puis, dans un module de test, on met la macro qui va bien dans le scope

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

    // ... vos tests vont ici ...
}

Et enfin, on peut écrire un test qui vérifie une propriété, par exemple la distributivité de la multiplication entière :

#[quickcheck]
fn distributivite(x: u32, y: u32, z: u32) {
    assert_eq!(
        x * (y + z),
        x * y + x * z
    );
}

Lorsqu’on lance ce test avec cargo test, quickcheck va l’exécuter automatiquement sur une série de valeurs d’entrée aléatoires, le générateur étant biaisé pour couvrir préférentiellement les cas tordus auxquels les développeurs ne pensent pas. En l’occurence, le test va échouer :

running 1 test
test tests::distributivite ... FAILED

failures:

---- tests::distributivite stdout ----
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at '[quickcheck] TEST FAILED (runtime error). Arguments: (0, 2153329322, 2141637974)
Error: "attempt to add with overflow"', /home/hadrien/.cargo/registry/src/index.crates.io-6f17d22bba15001f/quickcheck-1.0.3/src/tester.rs:165:28

Et nous découvrons alors avec stupeur que nous avons oublié de gérer le débordement d’entiers dans notre code. C’est le genre de problème que l’on ne trouve jamais avec des tests écrits à la main, parce que les développeurs ne pensent pas à mettre des grands nombres dans leurs tests.

Ce qui vous a peut-être un peu surpris, en revanche, c’est que le test a échoué plusieurs fois. La raison est que lorsque la bibliothèque quickcheck trouve un problème, elle essaie de réduire les données d’entrée à la configuration la plus simple qui cause le problème, en réduisant la taille des entiers, la taille des collections, etc, jusqu’à ce que le problème ne se présente plus.

Le message d’erreur final explique quelle est la configuration finalement obtenue :

[quickcheck] TEST FAILED (runtime error). Arguments: (0, 2153329322, 2141637974)
Error: "attempt to add with overflow"

…où l’on constate qu’il n’y a même pas besoin de multiplier pour avoir un débordement d’entiers, une addition suffit.

Il se trouve que les entiers non signés ont cette bonne propriété que même en présence de wraparound, le calcul reste distributif. On peut donc adapter notre code pour clarifier au compilateur que le débordement est prévu et souhaité ici, via l’utilisation du type Wrapping

use std::num::Wrapping;

#[quickcheck]
fn distributivite(x: Wrapping<u32>, y: Wrapping<u32>, z: Wrapping<u32>) {
    assert_eq!(
        x * (y + z),
        x * y + x * z
    );
}

…et le test passera :

running 1 test
test tests::distributivite ... ok

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

Cela nous permet de poser ici une leçon générale sur le test : le but de la procédure de test est d’en arriver à un point où la vision du monde contenue dans le test (qui est basée sur les propriétés recherchées) est cohérente avec la vision du monde contenue dans le code (qui est basée sur les besoins de l’implémentation). Ce qui a deux conséquences concrètes :

  1. Un test qui échoue ne signifie pas nécessairement qu’il y a un problème dans le code qui est testé, ça peut aussi être un problème avec le code du test.
  2. Un test qui ne fait que répéter le code qui est testé ne sert à rien. Pour être utile, le test doit être écrit d’un point de vue différent de l’implémentation, typiquement celui de l’utilisateur final de l’application. C’est une gymnastique mentale qui demande un peu de pratique au début.

Autres types de tests

En complément des tests unitaires, il est bon d’avoir des tests qui ne testent pas l’implémentation du programme à grain fin, mais l’interface externe et la façon dont les différents blocs du programme fonctionnent bien ensemble (ou pas). On parle de tests d’intégration ou de validation.

En dehors des exemples de documentation testés automatiquement, qui sont un test simple des interfaces externes, Cargo supporte l’écriture de tests d’intégration plus complexes sous la forme de binaires qui n’ont accès qu’à l’interface externe de la bibliothèque et qui sont construits et appelés automatiquement par cargo test. Je vous renvoie vers le tutoriel officiel pour plus d’informations.