Découvrez le test basé sur les propriétés avec la bibliothèque Hypothesis de Python. Dépassez les tests basés sur des exemples pour trouver les cas limites et construire des logiciels plus robustes et fiables.
Au-delà des tests unitaires : Une plongée en profondeur dans le test basé sur les propriétés avec Hypothesis de Python
Dans le monde du développement logiciel, les tests sont le fondement de la qualité. Pendant des décennies, le paradigme dominant a été le test basé sur des exemples. Nous élaborons méticuleusement des entrées, définissons les sorties attendues et écrivons des assertions pour vérifier que notre code se comporte comme prévu. Cette approche, que l'on retrouve dans des frameworks comme unittest
et pytest
, est puissante et essentielle. Mais que diriez-vous s'il existait une approche complémentaire capable de déceler des bogues que vous n'auriez même jamais pensé à chercher ?
Bienvenue dans le monde du test basé sur les propriétés, un paradigme qui déplace l'attention des tests d'exemples spécifiques vers la vérification des propriétés générales de votre code. Et dans l'écosystème Python, le champion incontesté de cette approche est une bibliothèque appelée Hypothesis.
Ce guide complet vous fera passer du statut de débutant complet à celui de praticien confiant du test basé sur les propriétés avec Hypothesis. Nous explorerons les concepts fondamentaux, nous plongerons dans des exemples pratiques et nous apprendrons à intégrer cet outil puissant dans votre flux de travail quotidien pour construire des logiciels plus robustes, fiables et résistants aux bogues.
Qu'est-ce que le test basé sur les propriétés ? Un changement de perspective
Pour comprendre Hypothesis, nous devons d'abord saisir l'idée fondamentale du test basé sur les propriétés. Comparons-le au test traditionnel basé sur des exemples que nous connaissons tous.
Le test basé sur des exemples : La voie familière
Imaginez que vous ayez écrit une fonction de tri personnalisée, my_sort()
. Avec le test basé sur des exemples, votre processus de pensée serait le suivant :
- « Testons-la avec une liste simple et ordonnée. » ->
assert my_sort([1, 2, 3]) == [1, 2, 3]
- « Et avec une liste dans l'ordre inverse ? » ->
assert my_sort([3, 2, 1]) == [1, 2, 3]
- « Que diriez-vous d'une liste vide ? » ->
assert my_sort([]) == []
- « Une liste avec des doublons ? » ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5]
- « Et une liste avec des nombres négatifs ? » ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
C'est efficace, mais cela présente une limite fondamentale : vous ne testez que les cas auxquels vous pouvez penser. Vos tests ne valent que ce que vaut votre imagination. Vous pourriez manquer des cas limites impliquant de très grands nombres, des imprécisions de nombres à virgule flottante, des caractères Unicode spécifiques ou des combinaisons complexes de données qui entraînent un comportement inattendu.
Le test basé sur les propriétés : Penser en termes d'invariants
Le test basé sur les propriétés renverse le scénario. Au lieu de fournir des exemples spécifiques, vous définissez les propriétés, ou invariants, de votre fonction — des règles qui doivent rester vraies pour n'importe quelle entrée valide. Pour notre fonction my_sort()
, ces propriétés pourraient être :
- La sortie est triée : Pour toute liste de nombres, chaque élément de la liste de sortie est inférieur ou égal à celui qui le suit.
- La sortie contient les mêmes éléments que l'entrée : La liste triée n'est qu'une permutation de la liste originale ; aucun élément n'est ajouté ou perdu.
- La fonction est idempotente : Trier une liste déjà triée ne devrait pas la modifier. C'est-à-dire,
my_sort(my_sort(une_liste)) == my_sort(une_liste)
.
Avec cette approche, vous n'écrivez pas les données de test. Vous écrivez les règles. Vous laissez ensuite un framework, comme Hypothesis, générer des centaines ou des milliers d'entrées aléatoires, diverses et souvent retorses pour tenter de prouver que vos propriétés sont fausses. S'il trouve une entrée qui brise une propriété, il a trouvé un bogue.
Présentation d'Hypothesis : Votre générateur de données de test automatisé
Hypothesis est la principale bibliothèque de test basé sur les propriétés pour Python. Elle prend les propriétés que vous définissez et se charge du travail difficile de génération de données de test pour les mettre à l'épreuve. Ce n'est pas seulement un générateur de données aléatoires ; c'est un outil intelligent et puissant conçu pour trouver des bogues efficacement.
Caractéristiques clés d'Hypothesis
- Génération automatique de cas de test : Vous définissez la *forme* des données dont vous avez besoin (par ex., « une liste d'entiers », « une chaîne de caractères ne contenant que des lettres », « une date/heure dans le futur »), et Hypothesis génère une grande variété d'exemples conformes à cette forme.
- Réduction intelligente (Intelligent Shrinking) : C'est la fonctionnalité magique. Quand Hypothesis trouve un cas de test qui échoue (par ex., une liste de 50 nombres complexes qui fait planter votre fonction de tri), il ne se contente pas de signaler cette liste énorme. Il simplifie intelligemment et automatiquement l'entrée pour trouver le plus petit exemple possible qui cause encore l'échec. Au lieu d'une liste de 50 éléments, il pourrait signaler que l'échec se produit avec seulement
[inf, nan]
. Cela rend le débogage incroyablement rapide et efficace. - Intégration transparente : Hypothesis s'intègre parfaitement aux frameworks de test populaires comme
pytest
etunittest
. Vous pouvez ajouter des tests basés sur les propriétés à côté de vos tests basés sur des exemples existants sans changer votre flux de travail. - Riche bibliothèque de stratégies : Elle est livrée avec une vaste collection de « stratégies » intégrées pour générer tout, des entiers et chaînes de caractères simples aux structures de données complexes et imbriquées, en passant par les dates/heures tenant compte du fuseau horaire et même les tableaux NumPy.
- Test avec état (Stateful Testing) : Pour les systèmes plus complexes, Hypothesis peut tester des séquences d'actions pour trouver des bogues dans les transitions d'état, ce qui est notoirement difficile avec le test basé sur des exemples.
Pour commencer : Votre premier test avec Hypothesis
Mettons la main à la pâte. La meilleure façon de comprendre Hypothesis est de le voir en action.
Installation
D'abord, vous devrez installer Hypothesis et votre exécuteur de tests de choix (nous utiliserons pytest
). C'est aussi simple que :
pip install pytest hypothesis
Un exemple simple : Une fonction de valeur absolue
Considérons une fonction simple qui est censée calculer la valeur absolue d'un nombre. Une implémentation légèrement boguée pourrait ressembler à ceci :
# dans un fichier nommé `my_math.py` def custom_abs(x): """Une implémentation personnalisée de la fonction de valeur absolue.""" if x < 0: return -x return x
Maintenant, écrivons un fichier de test, test_my_math.py
. D'abord, l'approche traditionnelle avec pytest
:
# test_my_math.py (Basé sur des exemples) def test_abs_positive(): assert custom_abs(5) == 5 def test_abs_negative(): assert custom_abs(-5) == 5 def test_abs_zero(): assert custom_abs(0) == 0
Ces tests passent. Notre fonction semble correcte sur la base de ces exemples. Mais maintenant, écrivons un test basé sur les propriétés avec Hypothesis. Quelle est une propriété fondamentale de la fonction de valeur absolue ? Le résultat ne doit jamais être négatif.
# test_my_math.py (Basé sur les propriétés avec Hypothesis) from hypothesis import given from hypothesis import strategies as st from my_math import custom_abs @given(st.integers()) def test_abs_property_is_non_negative(x): """Propriété : La valeur absolue de tout entier est toujours >= 0.""" assert custom_abs(x) >= 0
Décortiquons cela :
from hypothesis import given, strategies as st
: Nous importons les composants nécessaires.given
est un décorateur qui transforme une fonction de test ordinaire en un test basé sur les propriétés.strategies
est le module où nous trouvons nos générateurs de données.@given(st.integers())
: C'est le cœur du test. Le décorateur@given
demande à Hypothesis d'exécuter cette fonction de test plusieurs fois. Pour chaque exécution, il générera une valeur en utilisant la stratégie fournie,st.integers()
, et la passera comme argumentx
à notre fonction de test.assert custom_abs(x) >= 0
: C'est notre propriété. Nous affirmons que quel que soit l'entierx
qu'Hypothesis invente, le résultat de notre fonction doit être supérieur ou égal à zéro.
Lorsque vous exécutez cela avec pytest
, il passera probablement pour de nombreuses valeurs. Hypothesis essaiera 0, -1, 1, de grands nombres positifs, de grands nombres négatifs, et plus encore. Notre fonction simple gère tout cela correctement. Maintenant, essayons une stratégie différente pour voir si nous pouvons trouver une faiblesse.
# Testons avec des nombres à virgule flottante @given(st.floats()) def test_abs_floats_property(x): assert custom_abs(x) >= 0
Si vous exécutez ceci, Hypothesis trouvera rapidement un cas d'échec !
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis a découvert que notre fonction, lorsqu'on lui donne float('nan')
(Not a Number), renvoie nan
. L'assertion nan >= 0
est fausse. Nous venons de trouver un bogue subtil que nous n'aurions probablement pas pensé à tester manuellement. Nous pourrions corriger notre fonction pour gérer ce cas, peut-être en levant une ValueError
ou en retournant une valeur spécifique.
Encore mieux, et si le bogue se produisait avec un nombre à virgule flottante très spécifique ? Le réducteur (shrinker) d'Hypothesis aurait pris un grand nombre complexe qui échouait et l'aurait réduit à la version la plus simple possible qui déclenche encore le bogue.
La puissance des stratégies : Élaborer vos données de test
Les stratégies sont le cœur d'Hypothesis. Ce sont des recettes pour générer des données. La bibliothèque comprend un large éventail de stratégies intégrées, et vous pouvez les combiner et les personnaliser pour générer pratiquement n'importe quelle structure de données que vous pouvez imaginer.
Stratégies intégrées courantes
- Numérique :
st.integers(min_value=0, max_value=1000)
: Génère des entiers, optionnellement dans une plage spécifique.st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
: Génère des nombres à virgule flottante, avec un contrôle fin sur les valeurs spéciales.st.fractions()
,st.decimals()
- Texte :
st.text(min_size=1, max_size=50)
: Génère des chaînes de caractères unicode d'une certaine longueur.st.text(alphabet='abcdef0123456789')
: Génère des chaînes à partir d'un jeu de caractères spécifique (par ex., pour les codes hexadécimaux).st.characters()
: Génère des caractères individuels.
- Collections :
st.lists(st.integers(), min_size=1)
: Génère des listes où chaque élément est un entier. Notez comment nous passons une autre stratégie en argument ! C'est ce qu'on appelle la composition.st.tuples(st.text(), st.booleans())
: Génère des tuples avec une structure fixe.st.sets(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
: Génère des dictionnaires avec des types de clés et de valeurs spécifiés.
- Temporel :
st.dates()
,st.times()
,st.datetimes()
,st.timedeltas()
. Celles-ci peuvent être adaptées aux fuseaux horaires.
- Divers :
st.booleans()
: GénèreTrue
ouFalse
.st.just('valeur_constante')
: Génère toujours la même valeur unique. Utile pour composer des stratégies complexes.st.one_of(st.integers(), st.text())
: Génère une valeur à partir de l'une des stratégies fournies.st.none()
: Génère uniquementNone
.
Combiner et transformer des stratégies
La véritable puissance d'Hypothesis vient de sa capacité à construire des stratégies complexes à partir de plus simples.
Utiliser .map()
La méthode .map()
vous permet de prendre une valeur d'une stratégie et de la transformer en autre chose. C'est parfait pour créer des objets de vos classes personnalisées.
# Une simple data class from dataclasses import dataclass @dataclass class User: user_id: int username: str # Une stratégie pour générer des objets User user_strategy = st.builds( User, user_id=st.integers(min_value=1), username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz') ) @given(user=user_strategy) def test_user_creation(user): assert isinstance(user, User) assert user.user_id > 0 assert user.username.isalpha()
Utiliser .filter()
et assume()
Parfois, vous devez rejeter certaines valeurs générées. Par exemple, vous pourriez avoir besoin d'une liste d'entiers dont la somme n'est pas nulle. Vous pourriez utiliser .filter()
:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
Cependant, utiliser .filter()
peut être inefficace. Si la condition est souvent fausse, Hypothesis pourrait passer beaucoup de temps à essayer de générer un exemple valide. Une meilleure approche consiste souvent à utiliser assume()
à l'intérieur de votre fonction de test :
from hypothesis import assume @given(st.lists(st.integers())) def test_something_with_non_zero_sum_list(numbers): assume(sum(numbers) != 0) # ... votre logique de test ici ...
assume()
dit à Hypothesis : « Si cette condition n'est pas remplie, écarte simplement cet exemple et essaies-en un nouveau. » C'est une manière plus directe et souvent plus performante de contraindre vos données de test.
Utiliser st.composite()
Pour la génération de données vraiment complexes où une valeur générée dépend d'une autre, st.composite()
est l'outil dont vous avez besoin. Il vous permet d'écrire une fonction qui prend une fonction spéciale draw
en argument, que vous pouvez utiliser pour extraire des valeurs d'autres stratégies étape par étape.
Un exemple classique est de générer une liste et un index valide dans cette liste.
@st.composite def list_and_index(draw): # D'abord, tirer une liste non vide my_list = draw(st.lists(st.integers(), min_size=1)) # Ensuite, tirer un index qui est garanti d'être valide pour cette liste index = draw(st.integers(min_value=0, max_value=len(my_list) - 1)) return (my_list, index) @given(data=list_and_index()) def test_list_access(data): my_list, index = data # Cet accès est garanti d'être sûr grâce à la façon dont nous avons construit la stratégie element = my_list[index] assert element is not None # Une assertion simple
Hypothesis en action : Scénarios du monde réel
Appliquons ces concepts à des problèmes plus réalistes auxquels les développeurs de logiciels sont confrontés chaque jour.
Scénario 1 : Tester une fonction de sérialisation de données
Imaginez une fonction qui sérialise un profil utilisateur (un dictionnaire) en une chaîne de caractères URL-safe et une autre qui le désérialise. Une propriété clé est que le processus doit être parfaitement réversible.
import json import base64 def serialize_profile(data: dict) -> str: """Sérialise un dictionnaire en une chaîne base64 URL-safe.""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """Désérialise une chaîne pour la reconvertir en dictionnaire.""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # Maintenant, le test # Nous avons besoin d'une stratégie qui génère des dictionnaires compatibles JSON json_dictionaries = st.dictionaries( keys=st.text(), values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=10) ) @given(profile=json_dictionaries) def test_serialization_roundtrip(profile): """Propriété : La désérialisation d'un profil encodé doit retourner le profil original.""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
Ce seul test va bombarder nos fonctions avec une immense variété de données : des dictionnaires vides, des dictionnaires avec des listes imbriquées, des dictionnaires avec des caractères unicode, des dictionnaires avec des clés étranges, et plus encore. C'est bien plus approfondi que d'écrire quelques exemples manuels.
Scénario 2 : Tester un algorithme de tri
Revenons à notre exemple de tri. Voici comment vous testeriez les propriétés que nous avons définies plus tôt.
from collections import Counter def my_buggy_sort(numbers): # Introduisons un bogue subtil : il supprime les doublons return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # Propriété 1 : La sortie est triée for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # Propriété 2 : Les éléments sont les mêmes (ceci trouvera le bogue) assert Counter(numbers) == Counter(sorted_list) # Propriété 3 : La fonction est idempotente assert my_buggy_sort(sorted_list) == sorted_list
Lorsque vous exécutez ce test, Hypothesis trouvera rapidement un exemple d'échec pour la Propriété 2, tel que numbers=[0, 0]
. Notre fonction renvoie [0]
, et Counter([0, 0])
n'est pas égal à Counter([0])
. Le réducteur s'assurera que l'exemple d'échec est aussi simple que possible, rendant la cause du bogue immédiatement évidente.
Scénario 3 : Test avec état
Pour les objets avec un état interne qui change au fil du temps (comme une connexion à une base de données, un panier d'achat ou un cache), trouver des bogues peut être incroyablement difficile. Une séquence spécifique d'opérations peut être nécessaire pour déclencher une erreur. Hypothesis fournit `RuleBasedStateMachine` exactement à cette fin.
Imaginez une API simple pour un magasin clé-valeur en mémoire :
class SimpleKeyValueStore: def __init__(self): self._data = {} def set(self, key, value): self._data[key] = value def get(self, key): return self._data.get(key) def delete(self, key): if key in self._data: del self._data[key] def size(self): return len(self._data)
Nous pouvons modéliser son comportement et le tester avec une machine à états :
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # Bundle() est utilisé pour passer des données entre les règles keys = Bundle('keys') @rule(target=keys, key=st.text(), value=st.integers()) def set_key(self, key, value): self.model[key] = value self.sut.set(key, value) return key @rule(key=keys) def delete_key(self, key): del self.model[key] self.sut.delete(key) @rule(key=st.text()) def get_key(self, key): model_val = self.model.get(key) sut_val = self.sut.get(key) assert model_val == sut_val @rule() def check_size(self): assert len(self.model) == self.sut.size() # Pour exécuter le test, il suffit de sous-classer la machine et unittest.TestCase # Dans pytest, vous pouvez simplement assigner le test à la classe de la machine TestKeyValueStore = KeyValueStoreMachine.TestCase
Hypothesis va maintenant exécuter des séquences aléatoires d'opérations `set_key`, `delete_key`, `get_key`, et `check_size`, essayant sans relâche de trouver une séquence qui provoque l'échec de l'une des assertions. Il vérifiera si l'obtention d'une clé supprimée se comporte correctement, si la taille est cohérente après plusieurs ajouts et suppressions, et de nombreux autres scénarios que vous ne penseriez peut-être pas à tester manuellement.
Bonnes pratiques et conseils avancés
- La base de données d'exemples : Hypothesis est intelligent. Lorsqu'il trouve un bogue, il sauvegarde l'exemple d'échec dans un répertoire local (
.hypothesis/
). La prochaine fois que vous lancerez vos tests, il rejouera d'abord cet exemple d'échec, vous donnant un retour immédiat que le bogue est toujours présent. Une fois que vous l'avez corrigé, l'exemple n'est plus rejoué. - Contrôler l'exécution des tests avec
@settings
: Vous pouvez contrôler de nombreux aspects de l'exécution des tests en utilisant le décorateur@settings
. Vous pouvez augmenter le nombre d'exemples, fixer un délai pour la durée d'exécution d'un seul exemple (pour attraper les boucles infinies), et désactiver certains contrôles de santé.@settings(max_examples=500, deadline=1000) # Exécuter 500 exemples, délai d'1 seconde @given(...) ...
- Reproduire les échecs : Chaque exécution d'Hypothesis affiche une valeur de graine (seed) (par ex.,
@reproduce_failure('version', 'seed')
). Si un serveur CI trouve un bogue que vous ne pouvez pas reproduire localement, vous pouvez utiliser ce décorateur avec la graine fournie pour forcer Hypothesis à exécuter exactement la même séquence d'exemples. - Intégration avec CI/CD : Hypothesis est parfaitement adapté à tout pipeline d'intégration continue. Sa capacité à trouver des bogues obscurs avant qu'ils n'atteignent la production en fait un filet de sécurité inestimable.
Le changement de perspective : Penser en propriétés
Adopter Hypothesis, c'est plus qu'apprendre une nouvelle bibliothèque ; c'est adopter une nouvelle façon de penser la correction de votre code. Au lieu de vous demander : « Quelles entrées devrais-je tester ? », vous commencez à vous demander : « Quelles sont les vérités universelles à propos de ce code ? »
Voici quelques questions pour vous guider lorsque vous essayez d'identifier des propriétés :
- Existe-t-il une opération inverse ? (par ex., sérialiser/désérialiser, chiffrer/déchiffrer, compresser/décompresser). La propriété est que l'exécution de l'opération et de son inverse doit donner l'entrée originale.
- L'opération est-elle idempotente ? (par ex.,
abs(abs(x)) == abs(x)
). Appliquer la fonction plus d'une fois devrait produire le même résultat que de l'appliquer une seule fois. - Existe-t-il une manière différente et plus simple de calculer le même résultat ? Vous pouvez tester que votre fonction complexe et optimisée produit le même résultat qu'une version simple et manifestement correcte (par ex., tester votre tri sophistiqué par rapport à la fonction intégrée
sorted()
de Python). - Qu'est-ce qui devrait toujours être vrai à propos de la sortie ? (par ex., la sortie d'une fonction `find_prime_factors` ne devrait contenir que des nombres premiers, et leur produit devrait être égal à l'entrée).
- Comment l'état change-t-il ? (Pour le test avec état) Quels invariants doivent être maintenus après toute opération valide ? (par ex., le nombre d'articles dans un panier d'achat ne peut jamais être négatif).
Conclusion : Un nouveau niveau de confiance
Le test basé sur les propriétés avec Hypothesis ne remplace pas le test basé sur des exemples. Vous avez toujours besoin de tests spécifiques, écrits à la main, pour la logique métier critique et les exigences bien comprises (par ex., « Un utilisateur du pays X doit voir le prix Y »).
Ce qu'Hypothesis fournit, c'est un moyen puissant et automatisé d'explorer le comportement de votre code et de vous prémunir contre les cas limites imprévus. Il agit comme un partenaire infatigable, générant des milliers de tests plus diversifiés et retors que n'importe quel humain ne pourrait raisonnablement en écrire. En définissant les propriétés fondamentales de votre code, vous créez une spécification robuste contre laquelle Hypothesis peut tester, vous donnant un nouveau niveau de confiance dans votre logiciel.
La prochaine fois que vous écrirez une fonction, prenez un moment pour penser au-delà des exemples. Demandez-vous : « Quelles sont les règles ? Qu'est-ce qui doit toujours être vrai ? » Ensuite, laissez Hypothesis faire le gros du travail en essayant de les briser. Vous serez surpris de ce qu'il trouve, et votre code n'en sera que meilleur.