Aller au contenu

Tests avancés en Rust : mocks, tests de propriétés et benchmarks

Vous savez déjà écrire un test avec #[test] et assert_eq! (sinon, commencez par Les tests en Rust). Pour de petits programmes, cela suffit. Mais un vrai projet soulève vite de nouvelles questions. Où ranger les tests ? Comment tester du code qui envoie des e-mails ou lit l’heure ? Comment trouver la seule valeur à laquelle vous n’aviez pas pensé ? Et votre version « plus rapide » l’est-elle vraiment ?

Imaginez un garage automobile. Les tests de base vérifient que chaque pièce fonctionne. Cette page ajoute le reste de l’atelier : un banc d’essai pour les pièces (fakes et mocks), une machine qui secoue la voiture avec des milliers de bosses au hasard (tests de propriétés), une photo de l’état de la voiture la dernière fois (snapshots) et un chronomètre fiable (benchmarks).

Une précision honnête avant de commencer : le bouton Run du Playground exécute main, pas les tests, et il ne dispose pas des crates de test utilisées plus bas. La plupart des exemples montrent donc la vraie sortie de cargo test et cargo bench, obtenue avec un petit projet sur un ordinateur ordinaire. Quand un programme a une fonction main, vous trouverez comme d’habitude le bouton Essayez par vous-même.

Voici l’organisation de la petite bibliothèque shop utilisée sur cette page :

shop/
├── Cargo.toml
├── src/
│ ├── lib.rs <- code + tests unitaires dans `mod tests`
│ └── orders.rs
├── tests/
│ ├── checkout.rs <- un test d'intégration
│ └── common/
│ └── mod.rs <- fonctions utilitaires partagées par les tests d'intégration
└── benches/
└── stock.rs <- un benchmark

Rust connaît trois sortes de tests, et cargo test les lance toutes :

  • Les tests unitaires (unit tests) vivent à côté du code, dans un bloc #[cfg(test)] mod tests. Ils voient aussi les fonctions privées.
  • Les tests d’intégration (integration tests) vivent dans le dossier tests/. Chaque fichier est compilé comme une crate séparée qui utilise votre bibliothèque de l’extérieur, exactement comme le ferait un utilisateur. Ils ne peuvent donc appeler que les éléments pub.
  • Les doc tests sont les exemples de code placés dans les commentaires ///. Ils prouvent que votre documentation fonctionne toujours. La page Documentation en Rust les présente en détail.

Voici une partie de src/lib.rs. Remarquez le doc test sur apply_discount, ainsi que la fonction utilitaire (helper) et la fixture (jeu de données de test) dans mod tests :

/// Une ligne d'un panier. Les prix sont en centimes.
#[derive(Debug, Clone, PartialEq)]
pub struct Item {
pub name: String,
pub price_cents: u32,
pub qty: u32,
}
pub fn cart_total(items: &[Item]) -> u32 {
items.iter().map(|i| i.price_cents * i.qty).sum()
}
/// Retire `percent` pour cent d'un prix.
///
/// ```
/// use shop::apply_discount;
///
/// assert_eq!(apply_discount(1000, 25), 750);
/// ```
pub fn apply_discount(price_cents: u32, percent: u32) -> u32 {
assert!(percent <= 100, "discount must be at most 100%, got {percent}%");
price_cents - price_cents * percent / 100
}
/// Transforme "12.50" en 1250 centimes.
pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Transforme 1250 centimes en "12.50".
pub fn format_price(cents: u32) -> String {
format!("{}.{:02}", cents / 100, cents % 100)
}
#[cfg(test)]
mod tests {
use super::*;
// Un helper : court à appeler, et tous les tests créent les articles de la même façon.
fn item(name: &str, price_cents: u32, qty: u32) -> Item {
Item { name: name.to_string(), price_cents, qty }
}
// Une fixture : un panier réaliste que plusieurs tests partagent.
fn sample_cart() -> Vec<Item> {
vec![item("coffee", 450, 2), item("bagel", 300, 1)]
}
#[test]
fn total_of_sample_cart() {
assert_eq!(cart_total(&sample_cart()), 1200);
}
#[test]
fn empty_cart_costs_nothing() {
assert_eq!(cart_total(&[]), 0);
}
#[test]
#[should_panic(expected = "at most 100%")]
fn discount_over_100_panics() {
apply_discount(1000, 150);
}
// Un test peut renvoyer un Result : vous pouvez alors utiliser `?` dedans.
#[test]
fn parses_a_price() -> Result<(), String> {
let cents = parse_price("12.50")?;
assert_eq!(cents, 1250);
Ok(())
}
#[test]
#[ignore = "slow: checks ten million prices"]
fn every_price_round_trips() {
for cents in 0..10_000_000 {
assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}
}

Quelques points à remarquer :

  • Les helpers et les fixtures sont de simples fonctions. Rust n’a pas de système de fixtures spécial, et on s’en passe très bien. Comme elles se trouvent dans #[cfg(test)], elles ne finissent jamais dans votre vrai programme.
  • #[should_panic(expected = "...")] ne réussit que si le message du panic contient ce texte. Un panic causé par un autre bug ne compte donc pas. Les tests en Rust l’explique avec d’autres exemples.
  • Les tests qui renvoient un Result vous permettent d’utiliser ?. Si le test renvoie Err, il échoue et affiche l’erreur, par exemple Error: "missing '.' in \"12,50\"".
  • #[ignore] saute par défaut un test lent ou particulier. Le texte après = en donne la raison, et cargo test l’affiche.

Les noms des tests et les messages affichés restent en anglais, pour correspondre à la vraie sortie de cargo test que vous verrez plus bas.

Le test d’intégration utilise la bibliothèque à travers son API publique. Son helper partagé se trouve dans tests/common/mod.rs :

tests/common/mod.rs
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> {
vec![
Item { name: "coffee".into(), price_cents: 450, qty: 2 },
Item { name: "bagel".into(), price_cents: 300, qty: 1 },
]
}
tests/checkout.rs
mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]
fn discounted_breakfast_total() {
let total = cart_total(&common::breakfast_cart());
let to_pay = apply_discount(total, 10);
println!("to pay: {}", format_price(to_pay));
assert_eq!(format_price(to_pay), "10.80");
}

Lancer cargo test affiche une section par sorte de test :

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 tests
test tests::every_price_round_trips ... ignored, slow: checks ten million prices
test tests::parses_a_price ... ok
test tests::empty_cart_costs_nothing ... ok
test tests::total_of_sample_cart ... ok
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 test
test discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 test
test src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Au quotidien, vous ne voulez pas toujours lancer tous les tests. Ces commandes vous aident. Tout ce qui suit un -- isolé est transmis au programme de test lui-même, pas à Cargo.

Terminal window
cargo test discount # seulement les tests dont le nom contient "discount"
cargo test --test checkout # seulement le fichier tests/checkout.rs
cargo test --lib # seulement les tests unitaires de src/
cargo test --doc # seulement les doc tests
cargo test -- --ignored # seulement les tests marqués #[ignore]
cargo test -- --include-ignored # tout, y compris les tests ignorés
cargo test -- --nocapture # affiche la sortie de println! des tests qui réussissent
cargo test -- --test-threads=1 # lance les tests un par un

Le filtre cherche une partie du nom complet du test, dans tous les fichiers de test. Ici, discount trouve un test unitaire et un test d’intégration :

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 test
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 test
test discounted_breakfast_total ... ok

Le test lent ne s’exécute que si vous le demandez. Dix millions d’allers-retours ont pris environ 3 secondes ici :

running 1 test
test tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20s

Par défaut, Rust masque la sortie de println! des tests qui réussissent, pour garder un rapport lisible. --nocapture l’affiche :

$ cargo test --test checkout -- --nocapture
running 1 test
to pay: 10.80
test discounted_breakfast_total ... ok

Certains bouts de code parlent au monde extérieur : ils envoient des e-mails, appellent une API ou lisent l’horloge. Vous ne voulez pas que vos tests envoient de vrais e-mails. Et vous ne pouvez pas tester « ce qui se passe à 21 h » si le code interroge toujours la vraie horloge.

La réponse idiomatique en Rust ne demande aucune bibliothèque. Décrivez la dépendance sous forme de trait, faites accepter à votre fonction n’importe quel type qui l’implémente, et écrivez un petit fake (une fausse version) pour les tests. Le vrai programme passe la vraie pièce. Les tests passent le fake.

Ici, la confirmation d’une commande envoie un e-mail au client, et les grosses commandes préviennent aussi l’équipe commerciale :

trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Le vrai mailer. Dans une vraie application, il appellerait un service d'e-mail.
struct ConsoleMailer;
impl Mailer for ConsoleMailer {
fn send(&mut self, to: &str, body: &str) {
println!("e-mail à {to} : {body}");
}
}
struct Order {
id: u32,
email: String,
total_cents: u32,
}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) {
let body = format!("Commande #{} confirmée", order.id);
mailer.send(&order.email, &body);
if order.total_cents >= 10_000 {
mailer.send("[email protected]", &format!("Grosse commande #{}", order.id));
}
}
fn main() {
let mut mailer = ConsoleMailer;
let small = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
let big = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&small, &mut mailer);
confirm_order(&big, &mut mailer);
}
#[cfg(test)]
mod tests {
use super::*;
// Un fake : il n'envoie rien, il note simplement chaque appel.
#[derive(Default)]
struct FakeMailer {
sent: Vec<(String, String)>,
}
impl Mailer for FakeMailer {
fn send(&mut self, to: &str, body: &str) {
self.sent.push((to.to_string(), body.to_string()));
}
}
#[test]
fn small_order_sends_one_email() {
let mut fake = FakeMailer::default();
let order = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, "[email protected]");
}
#[test]
fn big_order_also_tells_sales() {
let mut fake = FakeMailer::default();
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2);
assert_eq!(fake.sent[1], ("[email protected]".to_string(), "Grosse commande #8".to_string()));
}
}

Sortie :

e-mail à [email protected] : Commande #7 confirmée
e-mail à [email protected] : Commande #8 confirmée
e-mail à [email protected] : Grosse commande #8
▶ Essayez par vous-même

Cet exemple n’utilise aucune crate externe : dans le Playground, vous pouvez donc aussi choisir Test dans le menu à côté de Run :

running 2 tests
test tests::small_order_sends_one_email ... ok
test tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

confirm_order ne sait pas quel mailer elle reçoit, et ça lui est égal. C’est tout le secret. La même idée fonctionne pour un trait Clock (les tests utilisent une horloge bloquée sur 21 h), un PaymentGateway, ou un trait Storage qui s’appuie sur une HashMap dans les tests. Vous vous entraînerez avec la version horloge plus bas.

Écrire des fakes à la main convient très bien pour un ou deux traits. Quand vous en avez beaucoup, la crate mockall (en anglais) peut les générer pour vous. Ajoutez-la comme dev-dependency (dépendance de développement), pour qu’elle ne serve qu’aux tests et aux exemples (Cargo et les crates explique les sections de Cargo.toml) :

Terminal window
cargo add --dev mockall

Placez #[automock] sur le trait, et mockall crée un type MockMailer. La partie cfg_attr(test, ...) signifie « fais-le seulement pendant les tests » : le mock ne se retrouve donc jamais dans votre vrai build. Features Cargo et #[cfg] explique cfg plus en détail.

src/orders.rs
#[cfg(test)]
use mockall::automock;
#[cfg_attr(test, automock)]
pub trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Order et confirm_order sont les mêmes que plus haut.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_order_also_tells_sales() {
let mut mailer = MockMailer::new();
mailer
.expect_send()
.withf(|to, _body| to == "[email protected]")
.times(1)
.return_const(());
mailer
.expect_send()
.withf(|to, body| to == "[email protected]" && body == "Grosse commande #8")
.times(1)
.return_const(());
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut mailer);
}
}

Chaque expect_send() est une attente (expectation) : « je m’attends à ce que send soit appelée avec des arguments qui correspondent à withf, exactement une fois ». return_const(()) indique ce que renvoie le mock (ici, rien). Quand le mock est détruit à la fin du test, mockall vérifie que toutes les attentes ont été remplies.

Pour le voir attraper un bug, changez la règle de confirm_order en order.total_cents > 12_000. L’e-mail à l’équipe commerciale n’est alors jamais envoyé :

thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:
MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1

Ce message de mockall est en anglais : il dit que l’attente a été remplie 0 fois, moins que la fois attendue.

Fake ou mock ? Commencez par un fake écrit à la main. C’est du Rust ordinaire, facile à lire, sans dépendance supplémentaire. Passez à mockall quand vous avez beaucoup de traits à simuler, ou quand vous tenez vraiment à vérifier « appelée exactement N fois avec ces arguments ».

Un test normal vérifie un exemple que vous avez choisi : « 12.50 donne 1250 ». Un test de propriétés (property test) énonce une règle qui doit être vraie pour n’importe quelle entrée, puis une bibliothèque essaie des centaines d’entrées aléatoires pour la mettre en défaut. C’est comme engager quelqu’un de très patient pour essayer toutes les valeurs bizarres auxquelles vous n’aviez pas pensé.

Les bonnes propriétés ressemblent souvent à ceci :

  • Faire une chose deux fois l’annule : inverser une liste deux fois redonne la liste de départ.
  • Un aller-retour rend ce qu’on avait au départ : parse(format(x)) == x.
  • Un fait reste toujours vrai : une liste triée a la même longueur que la liste d’origine.

Ajoutez proptest (en anglais) avec cargo add --dev proptest, et écrivez les tests dans la macro proptest!. Chaque ligne nom in stratégie indique quel genre de valeurs aléatoires générer :

tests/properties.rs
use proptest::prelude::*;
use shop::{format_price, parse_price};
proptest! {
#[test]
fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) {
let mut copy = list.clone();
copy.reverse();
copy.reverse();
prop_assert_eq!(copy, list);
}
#[test]
fn price_survives_format_then_parse(cents in 0u32..10_000_000) {
prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}

any::<i32>() signifie « n’importe quel i32 », 0u32..10_000_000 un nombre dans cet intervalle, et vec(..., 0..100) construit des listes de 99 éléments au plus. Par défaut, chaque propriété est essayée sur 256 cas aléatoires. Dans proptest!, utilisez prop_assert_eq! plutôt que assert_eq!, pour que proptest puisse bien gérer l’échec. Les deux tests réussissent :

running 2 tests
test price_survives_format_then_parse ... ok
test reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Remarquez que c’est la même vérification que le test lent marqué #[ignore] vu plus haut. Ce test essayait les dix millions de prix en 3 secondes. Le test de propriétés en essaie 256 au hasard en un centième de seconde : il peut donc tourner à chaque fois.

Faisons maintenant une erreur exprès. Quelqu’un « améliore » format_price pour afficher un séparateur de milliers, comme 1,000.00. C’est plus joli, mais il oublie parse_price :

pub fn format_price(cents: u32) -> String {
let euros = cents / 100;
let euros = if euros >= 1000 {
format!("{},{:03}", euros / 1000, euros % 1000)
} else {
euros.to_string()
};
format!("{euros}.{:02}", cents % 100)
}

Voici la vraie sortie de cargo test --test properties, légèrement raccourcie :

---- price_survives_format_then_parse stdout ----
proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:
Test failed: assertion failed: `(left == right)`
left: `Err("bad euros in \"1,000.00\"")`,
right: `Ok(100000)` at tests/properties.rs:15.
minimal failing input: cents = 100000

Regardez minimal failing input: cents = 100000 (« plus petite entrée qui échoue »). La première valeur aléatoire en échec était sans doute quelque chose de compliqué, comme 7 384 912. Proptest la réduit ensuite (shrinking) : il essaie des valeurs de plus en plus simples qui échouent encore, et affiche la plus simple. 100000 centimes, c’est exactement 1,000.00, le premier prix qui reçoit une virgule. Cela mène tout droit au bug.

Proptest enregistre aussi le cas en échec dans un fichier .proptest-regressions à côté du test, et l’essaie en premier à chaque exécution suivante. Committez ce fichier, pour que le bug ne puisse jamais revenir discrètement. Si vous préférez une bibliothèque plus petite, quickcheck (en anglais) fait le même travail avec moins de fonctionnalités.

Certaines sorties sont longues : un ticket de caisse, un rapport d’erreurs, un gros document JSON. Écrire un assert_eq! avec tout le texte attendu est fastidieux. Un test de snapshot (instantané) enregistre la sortie dans un fichier la première fois, puis, aux exécutions suivantes, compare la nouvelle sortie à cette « photo ».

Avec insta (en anglais ; cargo add --dev insta), le test tient en une ligne :

pub fn receipt(items: &[Item]) -> String {
let mut out = String::new();
for i in items {
let line = format_price(i.price_cents * i.qty);
out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line));
}
out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items))));
out
}
#[test]
fn receipt_layout() {
insta::assert_snapshot!(receipt(&sample_cart()));
}

La première exécution échoue exprès et écrit un fichier .snap.new que vous pouvez relire. Une fois que vous l’acceptez (avec cargo insta review, de l’outil cargo-insta, ou en renommant le fichier en .snap), il est rangé dans src/snapshots/ :

---
source: src/lib.rs
assertion_line: 84
expression: receipt(&sample_cart())
---
coffee x2 9.00
bagel x1 3.00
TOTAL 12.00

Plus tard, si quelqu’un remplace "TOTAL" par "Total", le test échoue avec un diff clair :

-old snapshot
+new results
────────────┬───────────────────────────────────────────────────────────────────
1 1 │ coffee x2 9.00
2 2 │ bagel x1 3.00
3 │-TOTAL 12.00
3 │+Total 12.00
────────────┴───────────────────────────────────────────────────────────────────
To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`

Si le changement était voulu, vous acceptez le nouveau snapshot. Sinon, vous venez de trouver un bug. Les snapshots sont parfaits pour vérifier que « ceci ne change pas par accident », mais ils ne vous disent pas que la sortie est juste. Lisez attentivement le premier snapshot avant de l’accepter.

Si vous voulez savoir si un code est rapide, mesurez-le. Les intuitions se trompent étonnamment souvent. Chronométrer du code dans un #[test] ne marche pas bien non plus : les tests sont compilés sans optimisations, et une seule exécution est trop bruitée.

criterion (en anglais) est l’outil habituel en Rust stable. Il exécute votre code de nombreuses fois, fait les statistiques pour vous et garde en mémoire le dernier résultat pour vous dire si vous êtes devenu plus rapide ou plus lent. Ajoutez-le avec cargo add --dev criterion, puis déclarez le fichier de benchmark à Cargo et désactivez le harness (le lanceur de tests) intégré :

[[bench]]
name = "stock"
harness = false

Ce benchmark répond à une question : pour vérifier si un code produit est en stock, est-ce qu’un Vec ou un HashSet est plus rapide avec 1 000 codes ?

benches/stock.rs
use std::collections::HashSet;
use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) {
// 1 000 codes produit, comme "SKU-0", "SKU-1", ...
let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect();
let code_set: HashSet<String> = codes.iter().cloned().collect();
let wanted = String::from("SKU-999"); // le pire cas pour un Vec : le dernier
c.bench_function("vec contains", |b| {
b.iter(|| codes.contains(black_box(&wanted)))
});
c.bench_function("hashset contains", |b| {
b.iter(|| code_set.contains(black_box(&wanted)))
});
}
criterion_group!(benches, stock_lookup);
criterion_main!(benches);

b.iter(...) contient le code mesuré. std::hint::black_box cache une valeur à l’optimiseur. Sans lui, le compilateur pourrait remarquer que la réponse ne change jamais, la calculer une seule fois, et vous ne mesureriez plus rien.

Lancez-le avec cargo bench, qui compile toujours en mode optimisé (release). Sur votre ordinateur, les chiffres seront différents :

vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]
Found 2 outliers among 100 measurements (2.00%)
1 (1.00%) low mild
1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]
Found 8 outliers among 100 measurements (8.00%)
4 (4.00%) high mild
4 (4.00%) high severe

Comment le lire : les trois temps forment un intervalle. Celui du milieu est la meilleure estimation, et les deux autres disent « la vraie valeur se trouve très probablement entre ces deux-là ». Ici, le HashSet prend environ 19 nanosecondes et le Vec environ 2,4 microsecondes : l’ensemble est donc plus de 100 fois plus rapide dans ce cas. Les « outliers » (valeurs aberrantes) sont quelques exécutions anormalement lentes ou rapides, souvent dues à d’autres programmes sur votre ordinateur. En avoir quelques-uns est normal.

Relancez-le après une modification, et criterion compare avec l’exécution précédente :

vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs]
change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05)
No change in performance detected.

Le changement est inférieur à 1 % et pourrait n’être que du bruit : criterion dit donc « No change » (aucun changement). Il écrit aussi un rapport HTML dans target/criterion/. Pour savoir quand (et quand ne pas) vous soucier de la vitesse, voyez Performance en Rust.

Dans du code ancien, vous croiserez peut-être des fonctions #[bench]. Elles utilisent le test::Bencher intégré, qui ne fonctionne qu’avec le compilateur nightly :

#![feature(test)] // nightly uniquement
extern crate test;
#[bench]
fn bench_total(b: &mut test::Bencher) {
b.iter(|| (1..=1000u64).sum::<u64>());
}

En Rust stable, utilisez criterion.

Une fois que vous avez des tests, deux autres outils sont utiles. Vous les installez avec cargo install, comme les autres outils présentés dans Les outils de Rust.

cargo-nextest est un lanceur de tests plus rapide. Il exécute chaque test dans son propre processus, affiche une sortie plus claire et peut relancer les tests instables (flaky). Une chose à savoir : il n’exécute pas les doc tests, alors lancez aussi cargo test --doc.

Terminal window
cargo install cargo-nextest --locked
cargo nextest run

La couverture de code (coverage) vous indique quelles lignes vos tests ont réellement exécutées. cargo-llvm-cov utilise la prise en charge de la couverture intégrée au compilateur et fonctionne sous Linux, macOS et Windows :

Terminal window
cargo install cargo-llvm-cov
cargo llvm-cov # tableau récapitulatif dans le terminal
cargo llvm-cov --html # un rapport à parcourir dans target/llvm-cov/html

cargo-tarpaulin est une alternative plus ancienne et populaire. Il fonctionne surtout bien sous Linux et c’est un choix courant en CI :

Terminal window
cargo install cargo-tarpaulin
cargo tarpaulin --out Html

Voyez la couverture comme une carte, pas comme une note. Elle montre le code qu’aucun test ne touche. Elle ne peut pas vous dire si vos assertions vérifient la bonne chose. 80 % de couverture avec de bonnes assertions valent mieux que 100 % avec des assertions faibles.

En intégration continue (CI : GitHub Actions, GitLab, etc.), un bon point de départ consiste à lancer les trois mêmes commandes qu’en local, pour qu’un échec signifie la même chose partout :

Terminal window
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test

Des tests d’intégration pour une crate uniquement binaire. Si votre projet n’a que src/main.rs, les fichiers de tests/ ne peuvent rien en importer :

error[E0432]: unresolved import `todo`
--> tests/tasks.rs:1:5
|
1 | use todo::add_task;
| ^^^^ use of unresolved module or unlinked crate `todo`

Le compilateur dit qu’il ne trouve ni module ni crate nommé todo. La solution : déplacez la logique dans src/lib.rs et gardez main.rs tout petit. main.rs appelle alors la bibliothèque, tout comme vos tests. En prime, le code devient plus facile à réutiliser.

Mettre les helpers partagés dans tests/common.rs. Chaque fichier .rs placé directement dans tests/ est traité comme un fichier de test à part entière, ce qui ajoute une section inutile dans la sortie :

Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 tests

La solution : utilisez tests/common/mod.rs, avec mod common; dans chaque fichier de test qui en a besoin.

Des crates de test dans [dependencies]. proptest, mockall, insta et criterion vont dans [dev-dependencies] (utilisez cargo add --dev). Sinon, chaque utilisateur de votre crate doit les télécharger et les compiler.

Des tests qui partagent un état. Les tests s’exécutent en parallèle. Si deux tests écrivent dans le même fichier ou modifient la même variable d’environnement, ils échouent au hasard. Donnez à chaque test son propre dossier temporaire (la crate tempfile aide) ou, en dernier recours, lancez-les avec --test-threads=1.

Faire un benchmark sans black_box, ou dans un test. L’optimiseur peut supprimer un calcul dont le résultat n’est pas utilisé, et un chronométrage dans un #[test] en mode debug ne vous apprend pas grand-chose. Utilisez criterion avec black_box.

Cette fonction ne peut pas être testée de façon fiable, car elle interroge toujours la vraie horloge :

fn greeting() -> &'static str {
let hour = current_hour(); // lit l'heure du système
match hour {
5..=11 => "Bonjour",
12..=17 => "Bon après-midi",
_ => "Bonsoir",
}
}

Réécrivez-la pour qu’elle reçoive un trait Clock avec une seule méthode, fn hour(&self) -> u64. Écrivez une FixedClock qui renvoie toujours la même heure, et testez les limites : 11 et 12, 17 et 18. Indice : struct FixedClock(u64); suffit. La vraie application passerait une SystemClock qui lit l’heure.

Voir la solution
trait Clock {
fn hour(&self) -> u64; // de 0 à 23
}
// Une horloge qui indique toujours la même heure. Parfaite pour les tests.
struct FixedClock(u64);
impl Clock for FixedClock {
fn hour(&self) -> u64 {
self.0
}
}
fn greeting(clock: &impl Clock) -> &'static str {
match clock.hour() {
5..=11 => "Bonjour",
12..=17 => "Bon après-midi",
_ => "Bonsoir",
}
}
fn main() {
for hour in [8, 12, 21] {
println!("{hour}:00 -> {}", greeting(&FixedClock(hour)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn morning_ends_at_noon() {
assert_eq!(greeting(&FixedClock(11)), "Bonjour");
assert_eq!(greeting(&FixedClock(12)), "Bon après-midi");
}
#[test]
fn evening_starts_at_six() {
assert_eq!(greeting(&FixedClock(17)), "Bon après-midi");
assert_eq!(greeting(&FixedClock(18)), "Bonsoir");
}
#[test]
fn very_early_is_still_evening() {
assert_eq!(greeting(&FixedClock(3)), "Bonsoir");
}
}

Sortie :

8:00 -> Bonjour
12:00 -> Bon après-midi
21:00 -> Bonsoir
▶ Essayez par vous-même

greeting ne décide plus d’où vient l’heure : c’est son appelant qui le fait. Ce seul changement rend chaque heure testable en quelques millisecondes. Choisissez Test dans le menu du Playground : les 3 tests réussissent.

2. Trouver la règle, puis la faire vérifier par proptest

Section intitulée « 2. Trouver la règle, puis la faire vérifier par proptest »

Voici une fonction qui transforme un tag de blog comme " Web Dev " en "web-dev" :

fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}

Écrivez deux tests de propriétés avec proptest : normaliser un tag déjà normalisé ne change rien, et le résultat ne contient jamais d’espace blanc. Indice : la stratégie ".*" génère des chaînes de caractères aléatoires (c’est une expression régulière). Pour « aucun espace blanc », prop_assert!(!s.contains(char::is_whitespace)) fonctionne.

Voir la solution
fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}
fn main() {
for tag in ["Rust", " Web Dev ", "GAME dev"] {
println!("{tag:?} -> {:?}", normalize_tag(tag));
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn normalizing_twice_changes_nothing(tag in ".*") {
let once = normalize_tag(&tag);
prop_assert_eq!(normalize_tag(&once), once);
}
#[test]
fn result_has_no_whitespace(tag in ".*") {
let normalized = normalize_tag(&tag);
prop_assert!(!normalized.contains(char::is_whitespace));
}
}
}

Sortie :

"Rust" -> "rust"
" Web Dev " -> "web-dev"
"GAME dev" -> "game-dev"
▶ Essayez par vous-même

Run fonctionne dans le Playground, mais pas Test, car le Playground n’a pas proptest. Dans votre propre projet, ajoutez-le avec cargo add --dev proptest et lancez cargo test :

running 2 tests
test tests::result_has_no_whitespace ... ok
test tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

Les chaînes aléatoires contiennent des tabulations, des retours à la ligne et des lettres d’autres alphabets, que vous n’auriez sans doute jamais tapés dans un test écrit à la main. « Le faire deux fois revient à le faire une fois » est une propriété qui vaut la peine d’être vérifiée pour toute fonction de nettoyage.

  • Les tests unitaires vont dans #[cfg(test)] mod tests à côté du code, les tests d’intégration dans tests/ (avec les helpers partagés dans tests/common/mod.rs), et les doc tests dans les commentaires ///.
  • Utilisez #[ignore] pour les tests lents et lancez-les avec cargo test -- --ignored ; filtrez par nom avec cargo test un_nom, et affichez la sortie de println! avec -- --nocapture.
  • Pour tester du code qui parle au monde extérieur, placez cette dépendance derrière un trait et passez un fake dans les tests. Utilisez mockall quand écrire les fakes à la main devient pénible.
  • Les tests de propriétés (proptest) vérifient une règle sur des centaines d’entrées aléatoires et réduisent un échec à l’exemple le plus simple. Les tests de snapshot (insta) repèrent les changements accidentels dans les grosses sorties.
  • Mesurez la vitesse avec criterion et cargo bench, et enveloppez les entrées dans std::hint::black_box. #[bench] exige nightly.
  • cargo-nextest lance les tests plus vite ; cargo-llvm-cov et cargo-tarpaulin montrent quelles lignes vos tests couvrent.

Petit quiz

1. Plusieurs fichiers de tests/ ont besoin de la même fonction utilitaire. Où doit-elle aller ?

2. Que fait cargo test -- --ignored ?

3. Une propriété proptest échoue. Que présente proptest comme « minimal failing input » ?

4. Pourquoi les benchmarks criterion enveloppent-ils les valeurs dans std::hint::black_box ?

Score : 0 / 4

  • Writing Automated Tests — The Rust Book (en anglais) : le chapitre officiel pour écrire, lancer et organiser les tests unitaires et d’intégration.
  • Testing — Rust by Example (en anglais) : de courts exemples de tests unitaires, de doc tests, de tests d’intégration et de dev-dependencies.
  • The proptest book (en anglais) : stratégies, shrinking, configuration et comparaison entre proptest et quickcheck.
  • Documentation de mockall (en anglais) : #[automock], attentes, correspondance des arguments, valeurs de retour et mocks de structs.
  • Guide de Criterion.rs (en anglais) : premiers pas, lecture de la sortie, comparaison de fonctions et rapports HTML.