
Le property-based testing génère des centaines d'entrées aléatoires pour vérifier une propriété de votre code, puis réduit automatiquement toute entrée qui casse la propriété jusqu'au contre-exemple minimal. Avec la bibliothèque Hypothesis, vous décrivez une règle générale (« décoder ce qui a été encodé redonne l'original ») plutôt qu'une poignée d'exemples choisis à la main, et l'outil trouve les cas limites que vous n'auriez jamais tapés. Ce guide s'adresse aux développeuses et développeurs qui maîtrisent déjà pytest et veulent une couche de tests plus robuste : nous partons d'une fonction avec un bug subtil, écrivons une propriété, laissons Hypothesis exhiber le contre-exemple, puis corrigeons. Les exemples ont été exécutés avec Python 3.12 et Hypothesis 6.156.6.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Écrire une propriété avec
@givenau lieu d'empiler des exemples. - Comprendre le shrinking : la réduction automatique au contre-exemple minimal.
- Choisir les stratégies de génération (
st.integers,st.text,st.lists). - Tester des invariants comme le round-trip
decoder(encoder(x)) == x. - Exploiter les fixtures pytest avancées (paramétrage,
scope, factory). - Mesurer la couverture avec
pytest-covet découvrir le mutation testing.
Des tests par l'exemple aux tests par propriété
Section intitulée « Des tests par l'exemple aux tests par propriété »Un test unitaire classique fige un exemple : vous appelez la fonction avec une entrée connue et comparez la sortie à un résultat attendu. C'est lisible, mais vous ne testez que les cas auxquels vous avez pensé. Or les bugs vivent presque toujours dans les cas auxquels personne n'a pensé : la chaîne vide, le nombre à deux chiffres, le caractère accentué, la liste géante.
Le property-based testing renverse la logique. Au lieu d'écrire « pour l'entrée A j'attends B », vous décrivez une propriété qui doit rester vraie quelle que soit l'entrée : un tri produit toujours une liste triée de même longueur, une sérialisation suivie d'une désérialisation redonne l'objet initial, une addition reste commutative. Hypothesis génère alors une centaine d'entrées par test (100 par défaut), dont les valeurs piégeuses qu'il connaît par expérience, et cherche à falsifier votre propriété.
Un bug subtil : une compression RLE
Section intitulée « Un bug subtil : une compression RLE »Pour rendre la démonstration concrète, prenons une compression RLE (run-length encoding, codage par plages). L'idée est simple : remplacer une suite de caractères identiques par le caractère suivi de son nombre de répétitions. La chaîne "aaab" devient "a3b1". C'est un algorithme réel, utilisé dans les formats d'image anciens et les protocoles simples.
Voici une implémentation qui paraît correcte et passe une revue de code rapide. L'encodeur parcourt le texte, compte les répétitions et concatène chaque caractère avec son compteur.
def encoder(texte: str) -> str: if not texte: return "" resultat = [] precedent = texte[0] compte = 1 for caractere in texte[1:]: if caractere == precedent: compte += 1 else: resultat.append(f"{precedent}{compte}") precedent = caractere compte = 1 resultat.append(f"{precedent}{compte}") return "".join(resultat)
def decoder(code: str) -> str: resultat = [] i = 0 while i < len(code): caractere = code[i] compte = int(code[i + 1]) # bug : un seul chiffre lu resultat.append(caractere * compte) i += 2 return "".join(resultat)Le décodeur lit un caractère, puis un seul chiffre pour le compteur, et avance de deux positions. Sur "a3b1" tout va bien. Le piège est ailleurs : dès qu'une plage dépasse neuf répétitions, le compteur s'écrit sur deux chiffres ("a10"), et le décodeur ne lit que le premier. Ce cas limite est exactement ce qu'un test par l'exemple oublie.
Écrire la première propriété avec @given
Section intitulée « Écrire la première propriété avec @given »La propriété la plus naturelle pour une paire encodeur/décodeur est le round-trip : décoder ce qui a été encodé doit rendre l'original. En notation compacte, decoder(encoder(x)) == x pour tout texte. C'est un invariant, une affirmation vraie sur la totalité du domaine d'entrée, pas sur un échantillon.
Le décorateur @given transforme une fonction de test ordinaire en test par propriété. Son argument est une stratégie, un objet qui décrit comment fabriquer les entrées. Comme la RLE traite des plages, générer directement du texte aléatoire produit rarement dix caractères identiques d'affilée ; on décrit donc chaque plage comme un couple caractère plus nombre de répétitions, puis on reconstitue le texte.
from hypothesis import given, strategies as st
from rle import encoder, decoder
# Une plage : un caractere minuscule repete entre 1 et 20 fois.repetition = st.tuples( st.characters(min_codepoint=97, max_codepoint=122), st.integers(min_value=1, max_value=20),)
@given(st.lists(repetition, min_size=1))def test_round_trip(repetitions: list[tuple[str, int]]) -> None: texte = "".join(caractere * nombre for caractere, nombre in repetitions) assert decoder(encoder(texte)) == texteLa fonction ne prend aucune valeur en dur : Hypothesis lui injecte à chaque exécution une liste de plages différente. On construit le texte à tester à partir de ces plages, on l'encode, on le décode et on vérifie l'égalité. Une seule assertion, mais évaluée sur une centaine d'entrées à chaque exécution.
Hypothesis trouve le contre-exemple minimal
Section intitulée « Hypothesis trouve le contre-exemple minimal »Lancez le test avec pytest comme n'importe quel autre. Aucune configuration supplémentaire n'est nécessaire une fois hypothesis installé dans l'environnement.
pip install hypothesis pytest pytest-covpytest test_rle.pyLe test échoue, et c'est la sortie qui rend Hypothesis précieux. L'outil ne se contente pas de dire « ça casse » : il affiche l'entrée exacte qui falsifie la propriété, après l'avoir réduite au plus petit cas possible.
ExceptionGroup: Hypothesis found 2 distinct failures. (2 sub-exceptions)+-+---------------- 1 ---------------- | ValueError: invalid literal for int() with base 10: 'a' | Falsifying example: test_round_trip( | repetitions=[('b', 10), ('a', 1)], | )+---------------- 2 ---------------- | IndexError: string index out of range | Falsifying example: test_round_trip( | repetitions=[('a', 10)], | )Le contre-exemple minimal est repetitions=[('a', 10)], c'est-à-dire le texte "aaaaaaaaaa" (dix a). L'encodeur produit "a10", mais le décodeur lit code[1] (le 1), s'arrête, puis tombe en dehors de la chaîne : d'où l'IndexError. Le second cas, [('b', 10), ('a', 1)], déclenche une ValueError parce que le décodeur essaie de convertir un caractère en entier. Hypothesis a isolé deux familles de bugs en un seul run.
Pourquoi le shrinking change tout
Section intitulée « Pourquoi le shrinking change tout »Sans réduction, Hypothesis aurait pu vous présenter un texte de trois cents caractères mélangeant lettres et longues plages : illisible, impossible à déboguer. Le shrinking est le mécanisme qui, une fois une entrée fautive trouvée, la simplifie pas à pas (moins d'éléments, valeurs plus petites, caractères plus proches de a) tant que la propriété reste fausse. Vous recevez donc le plus petit déclencheur du bug, celui qui pointe droit sur la cause.
Les stratégies : générer les bonnes données
Section intitulée « Les stratégies : générer les bonnes données »Une stratégie décrit l'ensemble des valeurs qu'Hypothesis peut produire, et surtout comment il les explore et les réduit. Le module hypothesis.strategies, importé sous l'alias st par convention, fournit des briques pour tous les types courants. Bien choisir sa stratégie, c'est cibler l'espace des entrées qui compte vraiment.
st.integers(min_value=0, max_value=255) # un octetst.text(min_size=1) # une chaine non videst.lists(st.floats(allow_nan=False)) # une liste de flottants finisst.dictionaries(st.text(), st.integers()) # un dictionnaireLes stratégies se combinent. On les assemble avec st.tuples et st.lists, on les transforme avec .map() (appliquer une fonction au résultat), on les restreint avec .filter() (rejeter les valeurs indésirables), et on les enchaîne avec .flatmap() quand une valeur générée en pilote une autre. C'est exactement ce que nous avons fait plus haut : st.tuples pour une plage, st.lists pour une suite de plages.
Corriger le bug et confirmer la propriété
Section intitulée « Corriger le bug et confirmer la propriété »Le contre-exemple nous a montré la cause : le décodeur ne lit qu'un chiffre. La correction consiste à lire tous les chiffres consécutifs du compteur avant de reconstituer la plage. On avance un curseur tant que le caractère courant est un chiffre, puis on convertit la tranche entière.
def decoder(code: str) -> str: resultat = [] i = 0 while i < len(code): caractere = code[i] j = i + 1 while j < len(code) and code[j].isdigit(): j += 1 compte = int(code[i + 1:j]) # lit un compteur multi-chiffres resultat.append(caractere * compte) i = j return "".join(resultat)On relance la propriété, en ajoutant au passage une seconde version qui génère du texte brut de lettres minuscules, pour couvrir les entrées ordinaires en plus des longues plages. Les deux propriétés passent : Hypothesis n'a plus trouvé de contre-exemple sur les centaines d'entrées essayées.
.. [100%]2 passed in 0.23sCe « 2 passed » a plus de valeur qu'un test par l'exemple qui passe : il signifie que la propriété a résisté à des entrées adverses, y compris celles qui cassaient la version précédente. La correction est validée par la même machine qui a révélé le bug.
Tester des invariants au-delà du round-trip
Section intitulée « Tester des invariants au-delà du round-trip »Le round-trip decoder(encoder(x)) == x est l'invariant le plus rentable, mais il en existe d'autres familles très utiles. Repérer un invariant correct est le vrai travail du property-based testing ; une fois trouvé, l'écrire est trivial.
- Idempotence : appliquer deux fois donne le même résultat qu'une fois, par exemple
normaliser(normaliser(x)) == normaliser(x). - Cohérence avec une référence : votre implémentation rapide doit renvoyer le même résultat qu'une version lente mais évidemment correcte.
- Propriété structurelle : la sortie d'un tri a la même longueur que l'entrée et est ordonnée, quelle que soit la liste.
- Oracle métier : le total d'une facture reste égal à la somme des lignes après remise.
@given(st.text())def test_normalisation_idempotente(brut: str) -> None: une_fois = brut.strip().lower() deux_fois = une_fois.strip().lower() assert deux_fois == une_foisL'invariant de cohérence est particulièrement puissant en refactoring : vous gardez l'ancienne fonction comme oracle, écrivez la nouvelle, et une propriété affirme qu'elles renvoient toujours la même chose. Hypothesis se charge de trouver l'entrée où elles divergent, s'il y en a une.
Le stateful testing pour les systèmes à état
Section intitulée « Le stateful testing pour les systèmes à état »Le round-trip teste une fonction pure. Beaucoup de bugs vivent pourtant dans des objets à état : un cache, une file, un modèle qui accumule des opérations. Le stateful testing d'Hypothesis génère non pas une entrée, mais une séquence d'appels de méthodes, et vérifie qu'un invariant tient après chaque appel.
On décrit le système avec une classe qui hérite de RuleBasedStateMachine. Chaque méthode décorée par @rule est une action possible, et Hypothesis en enchaîne des combinaisons aléatoires, puis réduit la séquence fautive comme il réduit une valeur.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
class FileTest(RuleBasedStateMachine): def __init__(self) -> None: super().__init__() self.modele: list[int] = [] self.file = MaFile() # le composant reel a tester
@rule(valeur=st.integers()) def enfiler(self, valeur: int) -> None: self.modele.append(valeur) self.file.enfiler(valeur)
@invariant() def meme_taille(self) -> None: assert len(self.modele) == self.file.taille()Pour que pytest exécute la machine, exposez sa classe de test générée avec TestFile = FileTest.TestCase en fin de module. L'intérêt est de reproduire des scénarios d'usage qu'aucun test scénarisé à la main n'aurait couverts : enfiler, défiler, vider, réenfiler dans un ordre imprévu. Le stateful testing est plus coûteux à mettre en place, on le réserve donc aux composants critiques dont l'état est difficile à raisonner.
Fixtures pytest avancées
Section intitulée « Fixtures pytest avancées »Les propriétés Hypothesis s'appuient sur le même socle pytest que vos autres tests, et les fixtures avancées de pytest restent pleinement disponibles. Une fixture prépare un contexte (base temporaire, client HTTP, jeu de données) et le fournit proprement à chaque test.
Le paramétrage avec @pytest.mark.parametrize rejoue un test sur plusieurs jeux de valeurs choisis, complémentaire des entrées aléatoires d'Hypothesis pour verrouiller des cas précis connus.
import pytest
@pytest.mark.parametrize("brut,attendu", [ ("", ""), ("a", "a1"), ("aaa", "a3"),])def test_exemples_connus(brut: str, attendu: str) -> None: assert encoder(brut) == attenduLe paramètre scope contrôle la durée de vie d'une fixture : function la recrée à chaque test (isolation maximale), tandis que module ou session la partagent pour éviter de reconstruire un objet coûteux comme une connexion. Enfin, le motif factory-as-fixture renvoie une fonction au lieu d'une valeur, ce qui laisse chaque test fabriquer autant d'objets qu'il veut, avec des paramètres différents.
@pytest.fixture(scope="session")def fabrique_utilisateur(): def _creer(nom: str, role: str = "lecteur") -> dict: return {"nom": nom, "role": role, "actif": True} return _creer
def test_deux_utilisateurs(fabrique_utilisateur) -> None: admin = fabrique_utilisateur("Camille", role="admin") invite = fabrique_utilisateur("Sacha") assert admin["role"] != invite["role"]La fixture factory est idéale pour les tests qui ont besoin de plusieurs instances d'un même objet : elle évite de multiplier les fixtures presque identiques et garde chaque test lisible. Combinée à un scope bien choisi, elle réduit le temps d'exécution sans sacrifier l'isolation.
Mesurer la couverture avec pytest-cov
Section intitulée « Mesurer la couverture avec pytest-cov »Écrire des propriétés ne garantit pas d'avoir exercé tout le code. La couverture (coverage) mesure quelles lignes ont été exécutées pendant les tests. Le greffon pytest-cov l'intègre directement dans la commande pytest, et l'option --cov-report=term-missing liste les lignes jamais atteintes.
pytest test_rle_fixed.py --cov=rle_fixed --cov-report=term-missingName Stmts Miss Cover Missing--------------------------------------------rle_fixed.py 26 0 100%--------------------------------------------TOTAL 26 0 100%Ici, la colonne Miss vaut zéro et la couverture atteint 100% : chaque ligne de l'encodeur et du décodeur a été franchie. Attention au piège classique : 100% de couverture ne veut pas dire zéro bug. Notre décodeur bogué initial pouvait très bien afficher une couverture totale tout en cassant sur "aaaaaaaaaa". La couverture répond à « quelles lignes tournent », pas à « le comportement est-il correct ». Les deux mesures sont complémentaires.
Mutation testing : qui teste vos tests
Section intitulée « Mutation testing : qui teste vos tests »La couverture dit qu'une ligne a été exécutée, jamais que votre test remarquerait un changement dans cette ligne. Le mutation testing comble ce trou : un outil comme mutmut introduit de petites mutations dans votre code (remplacer + par -, == par !=, < par <=), relance vos tests et vérifie qu'au moins un test échoue. Une mutation survivante signale un test aveugle.
pip install mutmutmutmut runmutmut resultsSi mutmut transforme votre condition et que tous les tests passent encore, c'est que rien ne vérifiait ce comportement : votre suite a un angle mort, malgré une couverture flatteuse. Le mutation testing est plus lent que la couverture, car il rejoue la suite pour chaque mutation ; on le lance donc plutôt en nocturne ou sur les modules sensibles, pas à chaque commit. C'est le juge de paix de la qualité réelle d'une suite de tests.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Le property-based testing vérifie une règle générale sur des centaines d'entrées générées, au lieu d'exemples choisis à la main.
@giventransforme un test pytest en test par propriété ; son argument est une stratégie de génération.- Le shrinking réduit toute entrée fautive au contre-exemple minimal, ici
"aaaaaaaaaa"pour notre bug RLE. - Le round-trip
decoder(encoder(x)) == xest l'invariant le plus rentable ; idempotence et cohérence avec un oracle en sont d'autres. - Les stratégies (
st.integers,st.text,st.lists) se combinent avec.map(),.filter()etst.tuples. - Le stateful testing (
RuleBasedStateMachine) enchaîne des appels pour tester les objets à état. pytest-covmesure la couverture, mais 100% ne prouve pas l'absence de bug.- Le mutation testing avec mutmut vérifie que vos tests détecteraient un changement de comportement.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Simuler avec les mocks : isoler une dépendance externe pour rendre une propriété reproductible.
- Automatiser avec tox : rejouer la suite de tests sur plusieurs versions de Python.
- Type hints en Python : annoter le code pour que mypy attrape une autre classe de bugs avant l'exécution.