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

SourceExempleConséquence typique
Race conditions entre plusieurs tâches qui partagent des Arc<Mutex<T>> ou RwLockdeux handlers modifient le même cache sans séquencement correctcorruption 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 asyncblocage 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 testcomportements différents en prod vs dev
Ordre d’exécution non déterministe des tâches concurrentesjoin! avec plusieurs futuresré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

PratiquePourquoi
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 :

CrateMaturitéParticularités
quickcheckStable depuis 2015API inspirée de Haskell QuickCheck, génération simple
proptestActif, riche en stratégiesContrô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éficeMé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ésTests automatiques dans le pipeline CI
Réduction du coût de maintenance : moins de scénarios écrits manuellementLe 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ématiquementDocumentation 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

PhaseAction cléLivrable
1️⃣ Audit initialRecenser les points d’entrée asynchrones (APIs, services externes)Cartographie des futures critiques
2️⃣ Mise en place du cadre de testAjouter tokio/async-std et choisir la crate de property‑testing (proptest) dans le Cargo.tomlFichier Cargo.toml mis à jour, CI configurée
3️⃣ Tests unitaires ciblésÉcrire un test par fonction asynchrone publique, en mockant les dépendancesSuite de tests unitaires > 80 % de couverture (cargo tarpaulin)
4️⃣ Scénarios d’intégrationCréer des environnements Docker composant bases de données, brokers, etc.Pipeline CI avec docker-compose up -d
5️⃣ Propriétés critiquesDéfinir les invariants métier (idempotence, absence de dead‑lock, conservation de l’état) et les implémenter via proptestSuite de tests property‑based intégrée au pipeline
6️⃣ Monitoring & métriquesAjouter le reporting du taux d’échecs, temps moyen des tests, nombre de cas générésDashboard (Grafana/Prometheus) affichant la santé du testing
7️⃣ GouvernanceFormaliser une politique “Testing Asynchrone” dans les guidelines de développementDocument 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é

ExigenceComment 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

Retour au blog

StratoSentry - 125 boulevard Saint-Denis, 92400 Courbevoie, France - contact@stratosentry.com