Vos tests unitaires ne couvrent que les cas auxquels vous avez pensé

Vous avez écrit une fonction reverse. Vous l’avez testée avec [1, 2, 3] et [4, 5, 6, 7]. Elle passe. Vous livrez.

Un utilisateur passe un slice à un seul élément. Votre fonction l’ignore. Il ouvre un ticket. Vous regardez le fichier de test et vous vous demandez comment vous avez pu manquer quelque chose d’aussi évident.

Vous l’avez manqué parce que le test par exemple ne détecte que les bugs que vous anticipez. Chaque assert_eq! dans votre suite de tests est une hypothèse sur l’endroit où l’échec pourrait se cacher. Le test basé sur les propriétés remplace ces hypothèses par des données aléatoires et des invariants mathématiques. Il trouvera le bug à un seul élément. Il trouvera aussi le bug du vecteur vide, le cas limite de dépassement d’entier, et le cas particulier Unicode dont vous ignoriez l’existence.

Des entrées aléatoires et des invariants, pas des exemples et des attentes

Le test basé sur les propriétés renverse la perspective. Au lieu de choisir des entrées et d’affirmer des sorties exactes, vous définissez une propriété qui doit être vraie pour toutes les entrées, et le framework de test génère des centaines d’entrées aléatoires pour essayer de la briser.

L’exemple classique est l’inversion d’une liste. La propriété n’est pas « l’inverse de [1, 2, 3] est [3, 2, 1] ». La propriété est « inverser une liste deux fois renvoie la liste originale ». Cet invariant est vrai pour toute liste possible, donc le framework lui envoie des listes aléatoires jusqu’à ce que quelque chose casse ou que la limite d’itérations soit atteinte.

Rust dispose de deux bibliothèques matures pour cela. quickcheck a été la première option populaire, portée depuis Haskell. proptest est l’approche plus récente avec une fonctionnalité décisive : le shrinking automatique. Quand proptest trouve une entrée défaillante, il ne se contente pas d’afficher un vecteur de cent éléments et de vous laisser déboguer. Il simplifie l’entrée jusqu’à trouver le contre-exemple le plus petit possible. C’est la différence entre lire une sortie de test échoué et réellement comprendre le bug.

Comment le shrinking révèle le vrai bug

Le shrinking est l’ingrédient secret. Disons que proptest génère un vecteur aléatoire de 47 entiers et que votre propriété échoue. Le bug n’est pas le 47e élément. Le bug concerne probablement les vecteurs vides, les changements de signe, ou les valeurs dupliquées.

proptest le sait. Il essaie de supprimer des éléments. Il essaie de mettre des valeurs à zéro. Il essaie de rendre le vecteur de plus en plus petit. Si le test échoue encore avec une entrée plus simple, il continue de réduire. Il s’arrête quand toute variation plus simple passe. Le résultat est le contre-exemple minimal.

Ce n’est pas un bonus. C’est la raison pour laquelle le test basé sur les propriétés est utilisable en pratique. Sans shrinking, vous retombez dans le débogage d’une botte de foin. Avec, vous obtenez une aiguille.

Un vrai bug, un vrai test

Voici une fonction reverse avec un bug subtil. Voyez si vous le repérez avant le test.

fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = Vec::with_capacity(xs.len());
    for i in 1..xs.len() {
        rev.push(xs[xs.len() - i].clone());
    }
    rev
}

La plage commence à 1 au lieu de 0. Pour toute liste non vide, le premier élément est silencieusement ignoré. Un test unitaire avec [1, 2, 3] pourrait passer si l’auteur n’a vérifié que la sortie semblait inversée, et non qu’elle avait la bonne longueur. Mais la propriété le détecte instantanément.

Ajoutez proptest à votre Cargo.toml :

[dev-dependencies]
proptest = "1.6"

Puis écrivez la propriété :

use proptest::prelude::*;

proptest! {
    #[test]
    fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
        let rev = reverse(&xs);
        let rev_rev = reverse(&rev);
        prop_assert_eq!(xs, rev_rev);
    }
}

Lancez cargo test et la sortie raconte l’histoire :

thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
  left: `[0]`,
 right: `[]`'

proptest a réduit un vecteur aléatoire défaillant à un seul élément, [0]. Le premier appel renvoie []. Le second appel renvoie []. La propriété exige [] == [0], ce qui échoue. Le bug est évident une fois l’entrée minimisée.

Vous pouvez aussi tester des propriétés structurelles directement :

proptest! {
    #[test]
    fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
        prop_assert_eq!(xs.len(), reverse(&xs).len());
    }
}

Ce test échoue avec la même entrée minimale mais donne une erreur encore plus claire : la longueur de sortie est 0, pas 1.

Pourquoi vous avez encore besoin de tests unitaires classiques

Le test basé sur les propriétés ne remplace pas les tests par exemple. Il les complète.

Les tests unitaires documentent l’intention. Quand un nouveau développeur lit que reverse(&[1, 2, 3]) devrait égaler [3, 2, 1], il comprend ce que la fonction est censée faire. Un test de propriété dit « cet invariant est vrai », mais il ne vous dit pas à quoi ressemble la sortie pour un cas normal.

Les propriétés sont aussi plus difficiles à écrire que les exemples. Les machines à états, les effets de bord et les E/S ne se mappent pas proprement aux fonctions pures. proptest a des stratégies pour les types complexes, mais les connecter demande plus de réflexion que quelques appels assert_eq!.

Il y a aussi le coût en temps. Un test de propriété peut exécuter mille itérations. Dans une grande base de code, ça s’accumule. Vous lancez les tests de propriété en CI, pas à chaque frappe de touche pendant le développement.

Les tests aléatoires peuvent quand même être reproductibles

Le caractère aléatoire rend les gens nerveux. Si un test échoue seulement parfois, est-ce le code ou le test ?

proptest gère cela en persistant les seeds défaillantes. Quand une propriété échoue, il sauvegarde la seed et l’entrée réduite dans un fichier dans proptest-regressions/. La prochaine exécution rejoue ce cas exact avant de générer de nouvelles données aléatoires. Un test de propriété flaky est presque toujours une propriété buggée, pas un framework flaky.

Si vous avez besoin d’un déterminisme strict, vous pouvez fixer la seed dans votre configuration de test. La plupart des équipes ne se donnent pas cette peine. Le fichier de régression suffit.

Où les tests de propriétés portent leurs fruits

Le test basé sur les propriétés vaut le surcoût quand votre code a des invariants mathématiques clairs et un grand espace d’entrées.

Le tri est l’exemple classique. Les propriétés sont simples : la sortie est triée, c’est une permutation de l’entrée, et elle a la même longueur. Un tri buggé peut passer des exemples écrits à la main mais échouer sur des données aléatoires avec des éléments dupliqués.

Les parsers et sérialiseurs sont un autre point fort. Les propriétés aller-retour comme parse(serialize(x)) == x détectent les bugs d’encodage qui n’apparaissent qu’avec des séquences d’octets spécifiques.

Tout ce qui implique de l’arithmétique, des collections, ou des transitions d’état en bénéficie. Tout ce qui implique des API externes, du timing strict, ou du jugement humain, non.

Commencez par une propriété

Vous n’avez pas besoin de réécrire votre suite de tests. Choisissez une fonction pure avec un invariant clair. Ajoutez proptest aux dev-dependencies. Écrivez une seule propriété. Lancez-la, regardez-la passer, puis introduisez un bug subtil et regardez-la réduire l’échec à quelque chose que vous pouvez déboguer en quelques secondes.

Une fois que vous verrez un test aléatoire trouver un bug pour lequel vous n’auriez jamais écrit d’exemple, vous commencerez à utiliser les propriétés plus souvent. Pas pour chaque test. Juste pour ceux qui comptent.