Tester le code asynchrone en Rust : des tests unitaires au testing basé sur les propriétés
Par Emmanuel Forgues - 8 octobre 2025
Chapô – Le langage Rust séduit par sa sûreté mémoire et ses performances, mais son modèle d’asynchronisme introduit des bugs difficiles à détecter. Entre la gestion du runtime (Tokio, async‑std), les futures qui se composent dynamiquement et les exigences de résilience logicielle, les équipes doivent repenser leurs stratégies de test. Cet article analyse les pratiques suivantes : tests unitaires classiques, tests d’intégration orientés tâches asynchrones, simulation de temps avec tokio::time, et testing basé sur les propriétés (property‑based testing) via des crates comme proptest ou quickcheck. Nous détaillons l'articulation de ces techniques, leurs bénéfices pour les organisations et les limites à considérer pour éviter les faux‑positifs et la surcharge de maintenance.
Publié initialement le 8 octobre 2025.
Mis à jour le 18 avril 2026.
Migré vers StratoSentry le 11 mai 2026.
Introduction – Un défi concret au cœur du développement moderne
Imaginez une plateforme de trading haute fréquence écrite en Rust, où chaque milliseconde compte. Le service d’ordres utilise le runtime Tokio pour paralléliser la réception des flux market data et l’envoi d’exécutions. Une mise à jour récente a introduit un nouveau futur qui combine plusieurs appels réseau conditionnels ; quelques minutes après le déploiement, les logs révèlent des dead‑locks intermittents et des pertes de messages. Le problème n’est pas visible en tests unitaires classiques, car ceux‑ci exécutent chaque fonction dans un environnement synchrone contrôlé.
Ce scénario illustre la réalité quotidienne : l’asynchronisme apporte une concurrence non bloquante mais rend les comportements temporels et d’interaction plus complexes. La question pour les décideurs techniques est donc : Comment garantir, de façon fiable et maintenable, que le code asynchrone fonctionne correctement dans toutes les conditions d’exécution ?
Nous proposons une approche graduée :
- Tests unitaires ciblés sur chaque future isolée.
- Tests d’intégration qui exécutent un runtime complet (Tokio ou async‑std).
- Simulation de temps pour reproduire les scénarios temporels complexes.
- Testing basé sur les propriétés afin de couvrir des espaces d’entrée vastes et imprévus.
Chaque niveau répond à des exigences différentes en matière de couverture, de coût de mise en œuvre et de valeur métier. L’enjeu est de réduire le temps moyen entre la découverte d’un défaut et son correctif, d’améliorer les indicateurs de fiabilité (MTTR, taux de régression) et de rassurer les parties prenantes – direction, équipes sécurité et conformité – sur la qualité du produit.
1. Les bases : pourquoi le testing asynchrone diffère du testing synchrone
1.1 Le modèle d’exécution Rust
Rust ne possède pas de garbage collector ; il repose sur un système de possession (ownership) et de prêts (borrowing). L’asynchronisme s’appuie sur des futures qui sont des valeurs paresseuses implémentant le trait Future. Un runtime (ex. : Tokio, async‑std) orchestre la progression de ces futures en les « polling » selon un planificateur d’évènements.
1.2 Sources de bugs spécifiques
| Source | Exemple | Conséquence typique |
|---|---|---|
| Race conditions entre plusieurs tâches qui partagent des Arc<Mutex<T>> ou RwLock | deux handlers modifient le même cache sans séquencement correct | corruption de données, panics |
| Dead‑locks lorsqu’une tâche attend indéfiniment sur un verrou déjà détenu par elle-même (via await dans une section critique) | utilisation d’un Mutex autour d’une opération I/O async | blocage du service |
| Timeouts et délais mal gérés (ex. : tokio::time::sleep) | dépendance à la vitesse du réseau non simulée en test | comportements différents en prod vs dev |
| Ordre d’exécution non déterministe des tâches concurrentes | join! avec plusieurs futures | résultats variables, difficile à reproduire |
Ces phénomènes ne se manifestent généralement pas dans un environnement de test synchrone où chaque appel est exécuté séquentiellement. Un cadre de test qui reproduit le scheduler et les timings du runtime s'avère donc nécessaire.
1.3 Implications organisationnelles
- Coût de la dette technique : ignorer les tests asynchrones augmente le risque de régressions coûteuses en production.
- Conformité & audit : certaines normes (ex. : ISO/IEC 27001) exigent la démonstration d’une stratégie de test complète, y compris pour les composants concurrents.
- Compétences requises : les équipes doivent maîtriser à la fois le modèle d’emprunt Rust et le fonctionnement du runtime choisi.
2. Tests unitaires : isoler chaque future
2.1 Structure de base d’un test unitaire async
#[cfg(test)]
mod tests {
use super::*;
use tokio::test; // macro qui crée un runtime pour chaque fonction
#[tokio::test]
async fn fetch_user_returns_ok() {
let repo = MockUserRepo::new(); // stub implémentant le trait UserRepository
let svc = UserService::new(repo);
let result = svc.fetch_user(42).await;
assert!(matches!(result, Ok(user) if user.id == 42));
}
}
- La macro #[tokio::test] démarre un runtime minimal (single‑threaded) ; elle évite de devoir créer manuellement un Runtime.
- Stubbing : les dépendances externes (bases de données, HTTP) sont remplacées par des implémentations mock qui retournent des réponses prévisibles.
2.2 Bonnes pratiques
| Pratique | Pourquoi |
|---|---|
| Injecter les dépendances via des traits (dyn AsyncRead, AsyncWrite) | Facilite le mocking et évite les appels réels pendant le test |
| Limiter la durée du test (ex. : < 200 ms) | Réduit la friction dans le pipeline CI |
| Vérifier les invariants de possession (Send/Sync) avec static_assertions::assert_impl_all! | Garantit que la future peut être exécutée par le runtime multi‑thread |
2.3 Limites des tests unitaires async
- Ne reproduisent pas l’interaction entre plusieurs tâches concurrentes.
- Peuvent masquer les problèmes de starvation ou d’ordre d’exécution.
- Dépendance forte aux mocks : si le mock ne reflète pas fidèlement le comportement réel (latence, erreurs réseau), le test peut être trompeur.
3. Tests d’intégration – faire tourner le runtime complet
3.1 Quand passer au niveau d’intégration
Lorsque la logique métier dépend de plusieurs futures qui s’exécutent en parallèle ou lorsqu’un composant interagit avec des services externes (Kafka, PostgreSQL) via un client asynchrone.
3.2 Exemple concret : serveur HTTP async‑std
#[tokio::test]
async fn health_endpoint_returns_200() {
// Démarrage d'un serveur en arrière-plan
let _ = tokio::spawn(async move { my_app::run().await });
// Attente de la disponibilité (polling simple)
for _ in 0..5 {
if let Ok(resp) = reqwest::get("http://127.0.0.1:8080/health").await {
assert_eq!(resp.status(), 200);
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("Le serveur n’est pas disponible après le délai attendu");
}
- Le test démarre le service complet dans un task séparé, puis effectue une requête HTTP réelle.
- Isolation : chaque test utilise un port libre (ex. portpicker) pour éviter les collisions.
3.3 Gestion du temps et des délais
Le runtime possède un planificateur d’évènements manipulable via tokio::time::pause() :
#[tokio::test]
async fn timeout_is_triggered() {
tokio::time::pause(); // désactive le comptage réel du temps
let fut = async {
tokio::time::sleep(Duration::from_secs(5)).await;
42
};
let handle = tokio::spawn(fut);
// Avance virtuelle de 5<0xE2><0x80><0xAF>s
tokio::time::advance(Duration::from_secs(5)).await;
assert_eq!(handle.await.unwrap(), 42);
}
Cette technique permet de simuler des scénarios de latence sans ralentir le pipeline CI.
3.4 Points de vigilance
- Les tests d’intégration sont plus lents ; ils doivent être limités à un sous‑ensemble critique (ex. : 10 % du total).
- La configuration de la base de données ou des services externes doit être reproductible (containers Docker, fixtures).
- Le nettoyage des ressources (ports, fichiers temporaires) est essentiel pour éviter les fuites entre exécutions.
4. Property‑Based Testing – explorer l’infini d’états possibles
4.1 Principe de base
Le property‑based testing génère automatiquement des entrées aléatoires (ou structurées) et vérifie qu’une propriété (invariant) tient pour toutes les valeurs générées. En Rust, les crates majeures sont :
| Crate | Maturité | Particularités |
|---|---|---|
| quickcheck | Stable depuis 2015 | API inspirée de Haskell QuickCheck, génération simple |
| proptest | Actif, riche en stratégies | Contrôle fin des tailles, shrinking puissant, support natif du runtime async via #[tokio::test] |
4.2 Exemple : vérifier l’idempotence d’une fonction de mise en cache
use proptest::prelude::*;
use tokio::sync::RwLock;
#[derive(Clone)]
struct Cache {
inner: Arc<RwLock<HashMap<String, String>>>,
]}
impl Cache {
async fn get_or_insert<F>(&self, key: &str, f: F) -> String
where
F: FnOnce() -> String,
{
let mut map = self.inner.write().await;
map.entry(key.to_string()).or_insert_with(f).clone()
}
}
// Property‑based test
proptest! {
#[test]
fn cache_is_idempotent(k in ".*", v in ".*") {
// Initialisation asynchrone via block_on (car proptest n’est pas async)
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let cache = Cache { inner: Arc::new(RwLock::new(HashMap::new())) };
let first = cache.get_or_insert(&k, || v.clone()).await;
let second = cache.get_or_insert(&k, || "different".to_string()).await;
prop_assert_eq!(first, second);
});
}
}
- Stratégies : proptest accepte des générateurs de chaînes (".*") ou d’autres types (vectors, structs).
- Le shrinking réduit automatiquement les cas qui déclenchent un échec pour faciliter le debugging.
4.3 Tester la non‑régression des dead‑locks
Une propriété fréquente : aucune tâche ne doit rester bloquée indéfiniment. On peut la formuler ainsi :
proptest! {
#[test]
fn no_deadlock_under_random_load(tasks in 1..10usize, delay_ms in 0..100u64) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
let barrier = Arc::new(tokio::sync::Barrier::new(tasks));
let mut handles = Vec::new();
for _ in 0..tasks {
let b = barrier.clone();
handles.push(tokio::spawn(async move {
// Simule un travail aléatoire
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
b.wait().await; // synchronisation
}));
}
// Timeout de sécurité : si le barrier ne se libère pas, on considère un dead‑lock
let timeout = tokio::time::timeout(Duration::from_secs(2), futures::future::join_all(handles));
assert!(timeout.await.is_ok(), "Dead‑lock détecté avec {} tâches", tasks);
});
}
}
Cette approche détecte des interblocages invisibles dans un test unitaire dédié.
4.4 Avantages pour les organisations
| Bénéfice | Mécanisme sous‑jacent |
|---|---|
| Couverture exhaustive de cas d’entrée rares (bordures, valeurs négatives) | Génération aléatoire massive |
| Détection précoce de régressions grâce à un baseline de propriétés | Tests automatiques dans le pipeline CI |
| Réduction du coût de maintenance : moins de scénarios écrits manuellement | Le shrinking fournit des cas minimalistes pour le debugging |
| Conformité aux exigences d’audit (ex. ISO/IEC 27001) – preuve que les invariants critiques sont vérifiés systématiquement | Documentation des propriétés testées |
4.5 Limites et contraintes
- Le property‑based testing n’est pas un substitut complet aux tests fonctionnels : il ne valide pas le comportement métier exact (ex. : format d’une réponse HTTP).
- Les générateurs doivent être bien ciblés ; sinon, la plupart des cas seront triviaux et l’efficacité chute.
- L’intégration avec les runtimes asynchrones nécessite souvent un runtime dédié dans chaque test (Runtime::new().block_on) ou le support natif de #[tokio::test] (disponible depuis proptest 1.0).
- Le temps d’exécution peut augmenter considérablement si le nombre de cas générés n’est pas limité : il faut ajuster les paramètres (ProptestConfig::with_cases(100)) en fonction du budget CI.
5. Stratégie de mise en œuvre – du prototype à la gouvernance d’entreprise
5.1 Étapes recommandées
| Phase | Action clé | Livrable |
|---|---|---|
| 1️⃣ Audit initial | Recenser les points d’entrée asynchrones (APIs, services externes) | Cartographie des futures critiques |
| 2️⃣ Mise en place du cadre de test | Ajouter tokio/async-std et choisir la crate de property‑testing (proptest) dans le Cargo.toml | Fichier Cargo.toml mis à jour, CI configurée |
| 3️⃣ Tests unitaires ciblés | Écrire un test par fonction asynchrone publique, en mockant les dépendances | Suite de tests unitaires > 80 % de couverture (cargo tarpaulin) |
| 4️⃣ Scénarios d’intégration | Créer des environnements Docker composant bases de données, brokers, etc. | Pipeline CI avec docker-compose up -d |
| 5️⃣ Propriétés critiques | Définir les invariants métier (idempotence, absence de dead‑lock, conservation de l’état) et les implémenter via proptest | Suite de tests property‑based intégrée au pipeline |
| 6️⃣ Monitoring & métriques | Ajouter le reporting du taux d’échecs, temps moyen des tests, nombre de cas générés | Dashboard (Grafana/Prometheus) affichant la santé du testing |
| 7️⃣ Gouvernance | Formaliser une politique “Testing Asynchrone” dans les guidelines de développement | Document interne signé par le DSI et le RSSI |
5.2 Impacts sur les coûts et les ressources
- Coût initial : temps d’ingénierie (≈ 1‑2 jours/personne pour chaque service) + mise en place du pipeline CI (Docker, GitHub Actions ou GitLab CI).
- Coût récurrent : exécution des tests property‑based augmente le temps de build de 10‑20 % ; cependant, les gains en réduction d’incidents post‑déploiement (estimés à 0,5 FTE par incident évité) compensent largement.
- Compétences : formation courte (2 h) sur proptest et le modèle async de Rust, accessible aux développeurs déjà familiers avec le langage.
5.3 Alignement avec les exigences de conformité
| Exigence | Comment le testing asynchrone y répond |
|---|---|
| ISO/IEC 27001 – A.12.1 (Gestion des changements) | Les tests automatisés assurent que chaque modification du code passe par une validation fonctionnelle et de sécurité avant mise en production. |
| PCI‑DSS 6.4 (Développement sécurisé) | Le property‑based testing aide à identifier les vulnérabilités liées aux conditions de concurrence