
Laissez Hypothesis briser votre code Python avant que vos utilisateurs ne le fassent
vous devriez prendre le test de votre code au sérieux. Vous pouvez écrire des tests unitaires avec pytest, des dépendances simulées et vous efforcer d’obtenir une couverture de code élevée. Si vous êtes comme moi, cependant, vous pourriez avoir une question lancinante qui vous reste à l’esprit après avoir fini de coder une suite de tests.
« Ai-je pensé à tous les cas extrêmes ? »
Vous pouvez tester vos entrées avec des nombres positifs, des nombres négatifs, des zéros et des chaînes vides. Mais qu’en est-il des caractères Unicode étranges ? Ou des nombres à virgule flottante qui sont NaN ou infinis ? Qu’en est-il d’une liste de listes de chaînes vides ou de JSON imbriqués complexes ? L’espace des entrées possibles est immense et il est difficile d’imaginer la myriade de façons différentes dont votre code pourrait être brisé, surtout si vous êtes soumis à une certaine pression de temps.
Les tests basés sur les propriétés éliminent ce fardeau toi à l’outillage. Au lieu de sélectionner des exemples à la main, vous énoncez un propriété – une vérité qui doit être valable tous entrées. La bibliothèque d’hypothèses alors génère entrées; plusieurs centaines si nécessaire, recherche des contre-exemples et, s’il en trouve un, rétrécit au cas d’échec le plus simple.
Dans cet article, je vais vous présenter le concept puissant des tests basés sur les propriétés et sa mise en œuvre dans Hypothesis. Nous irons au-delà des fonctions simples et vous montrerons comment tester des structures de données complexes et des classes avec état, ainsi que comment affiner Hypothesis pour des tests robustes et efficaces.
Alors, qu’est-ce que les tests basés sur les propriétés exactement ?
Les tests basés sur les propriétés sont une méthodologie dans laquelle, au lieu d’écrire des tests pour des exemples spécifiques et codés en dur, vous définissez les « propriétés » ou « invariants » généraux de votre code. Une propriété est une déclaration de haut niveau sur le comportement de votre code qui doit être valable pendant tous entrées valides. Vous utilisez ensuite un cadre de test, comme Hypothesis, qui génère intelligemment un large éventail d’entrées et essaie de trouver un « contre-exemple » – une entrée spécifique pour laquelle votre propriété déclarée est fausse.
Certains aspects clés des tests basés sur les propriétés avec Hypothesis incluent :
- Tests génératifs. Hypothesis génère pour vous des cas de test, du plus simple à l’inhabituel, en explorant les cas extrêmes que vous manqueriez probablement.
- Axé sur la propriété. Cela change votre état d’esprit de « quel est le résultat de cette entrée spécifique ? » à « quelles sont les vérités universelles sur le comportement de ma fonction ? »
- Contraction. C’est la fonctionnalité phare d’Hypothesis. Lorsqu’il détecte un scénario de test défaillant (qui peut être volumineux et complexe), il ne se contente pas de le signaler. Il « réduit » automatiquement l’entrée à l’exemple le plus petit et le plus simple possible qui provoque toujours l’échec, ce qui facilite souvent considérablement le débogage.
- Tests avec état. L’hypothèse peut tester non seulement des fonctions pures, mais également les interactions et les changements d’état d’objets complexes au cours d’une séquence d’appels de méthode.
- Stratégies extensibles. Hypothesis fournit une bibliothèque robuste de « stratégies » pour générer des données et vous permet de les composer ou d’en créer de entièrement nouvelles pour correspondre aux modèles de données de votre application.
Pourquoi l’hypothèse est importante / Cas d’utilisation courants
Le principal avantage des tests basés sur les propriétés est leur capacité à détecter des bogues subtils et à accroître votre confiance dans l’exactitude de votre code, bien au-delà de ce qui est possible avec les seuls tests basés sur des exemples. Cela vous oblige à réfléchir plus profondément aux contrats et hypothèses de votre code.
L’hypothèse est particulièrement efficace pour tester :
- Sérialisation/Désérialisation. Une propriété classique est que pour tout objet x, decode(encode(x)) doit être égal à x. C’est parfait pour tester des fonctions qui fonctionnent avec des formats JSON ou binaires personnalisés.
- Logique métier complexe. Toute fonction dotée d’une logique conditionnelle complexe est un excellent candidat. Hypothesis explorera des chemins à travers votre code que vous n’avez peut-être pas envisagés.
- Systèmes avec état. Tester les classes et les objets pour garantir qu’aucune séquence d’opérations valides ne peut mettre l’objet dans un état corrompu ou invalide.
- Test par rapport à une implémentation de référence. Vous pouvez affirmer la propriété selon laquelle votre nouvelle fonction optimisée doit toujours produire le même résultat qu’une implémentation de référence plus simple, connue et exemplaire.
- Fonctions qui acceptent des modèles de données complexes. Fonctions de test qui prennent des modèles Pydantic, des classes de données ou d’autres objets personnalisés en entrée.
Mise en place d’un environnement de développement
Tout ce dont vous avez besoin est Python et pip. Nous installerons pytest comme lanceur de tests, l’hypothèse elle-même et pydantic pour l’un de nos exemples avancés.
(base) tom@tpr-desktop:~$ python -m venv hyp-env
(base) tom@tpr-desktop:~$ source hyp-env/bin/activate
(hyp-env) (base) tom@tpr-desktop:~$
# Install pytest, hypothesis, and pydantic
(hyp-env) (base) tom@tpr-desktop:~$ pip install pytest hypothesis pydantic
# create a new folder to hold your python code
(hyp-env) (base) tom@tpr-desktop:~$ mkdir hyp-project
Il est préférable d’exécuter l’hypothèse en utilisant un outil d’exécution de tests établi comme pytest, c’est donc ce que nous allons faire ici.
Exemple de code 1 — Un test simple
Dans cet exemple le plus simple, nous avons une fonction qui calcule l’aire d’un rectangle. Il doit prendre deux paramètres entiers, tous deux supérieurs à zéro, et renvoyer leur produit.
Les tests d’hypothèses sont définis à l’aide de deux éléments : @donnédécorateur et un stratégie qui est transmis au décorateur. Considérez une stratégie comme les types de données que Hypothesis générera pour tester votre fonction. Voici un exemple simple. Tout d’abord, nous définissons la fonction que nous voulons tester.
# my_geometry.py
def calculate_rectangle_area(length: int, width: int) -> int:
"""
Calculates the area of a rectangle given its length and width.
This function raises a ValueError if either dimension is not a positive integer.
"""
if not isinstance(length, int) or not isinstance(width, int):
raise TypeError("Length and width must be integers.")
if length <= 0 or width <= 0:
raise ValueError("Length and width must be positive.")
return length * width
Vient ensuite la fonction de test.
# test_rectangle.py
from my_geometry import calculate_rectangle_area
from hypothesis import given, strategies as st
import pytest
# By using st.integers(min_value=1) for both arguments, we guarantee
# that Hypothesis will only generate valid inputs for our function.
@given(
length=st.integers(min_value=1),
width=st.integers(min_value=1)
)
def test_rectangle_area_with_valid_inputs(length, width):
"""
Property: For any positive integers length and width, the area
should be equal to their product.
This test ensures the core multiplication logic is correct.
"""
print(f"Testing with valid inputs: length={length}, width={width}")
# The property we are checking is the mathematical definition of area.
assert calculate_rectangle_area(length, width) == length * width
Ajout du @donné le décorateur de la fonction la transforme en test d’hypothèse. Passer la stratégie (st.integers) au décorateur indique que l’hypothèse doit générer des entiers aléatoires pour l’argument n lors des tests, mais nous limitons en outre cela en garantissant qu’aucun entier ne peut être inférieur à un.
Nous pouvons exécuter ce test en l’appelant de cette manière.
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_geometry.py
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item
test_my_geometry.py Testing with valid inputs: length=1, width=1
Testing with valid inputs: length=6541, width=1
Testing with valid inputs: length=6541, width=28545
Testing with valid inputs: length=1295885530, width=1
Testing with valid inputs: length=1295885530, width=25191
Testing with valid inputs: length=14538, width=1
Testing with valid inputs: length=14538, width=15503
Testing with valid inputs: length=7997, width=1
...
...
Testing with valid inputs: length=19378, width=22512
Testing with valid inputs: length=22512, width=22512
Testing with valid inputs: length=3392, width=44
Testing with valid inputs: length=44, width=44
.
============================================ 1 passed in 0.10s =============================================
Par défaut, Hypothesis effectuera 100 tests sur votre fonction avec différentes entrées. Vous pouvez augmenter ou diminuer ce chiffre en utilisant le paramètres décorateur. Par exemple,
from hypothesis import given, strategies as st,settings
...
...
@given(
length=st.integers(min_value=1),
width=st.integers(min_value=1)
)
@settings(max_examples=3)
def test_rectangle_area_with_valid_inputs(length, width):
...
...
#
# Outputs
#
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_geometry.py
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item
test_my_geometry.py
Testing with valid inputs: length=1, width=1
Testing with valid inputs: length=1870, width=5773964720159522347
Testing with valid inputs: length=61, width=25429
.
============================================ 1 passed in 0.06s =============================================
Exemple de code 2 — Test de la propriété classique « Round-Trip »
Regardons une propriété classique : – la sérialisation et la désérialisation doivent être réversibles. En bref, decode(encode(X)) devrait renvoyer X.
Nous allons écrire une fonction qui prend un dictionnaire et l’encode dans une chaîne de requête URL.
Créez un fichier dans votre dossier hyp-project nommé my_encoders.py.
# my_encoders.py
import urllib.parse
def encode_dict_to_querystring(data: dict) -> str:
# A bug exists here: it doesn't handle nested structures well
return urllib.parse.urlencode(data)
def decode_querystring_to_dict(qs: str) -> dict:
return dict(urllib.parse.parse_qsl(qs))
Ce sont deux fonctions élémentaires. Qu’est-ce qui pourrait mal leur arriver ? Testons-les maintenant dans test_encoders.py :
# test_encoders.py
# test_encoders.py
from hypothesis import given, strategies as st
# A strategy for generating dictionaries with simple text keys and values
simple_dict_strategy = st.dictionaries(keys=st.text(), values=st.text())
@given(data=simple_dict_strategy)
def test_querystring_roundtrip(data):
"""Property: decoding an encoded dict should yield the original dict."""
encoded = encode_dict_to_querystring(data)
decoded = decode_querystring_to_dict(encoded)
# We have to be careful with types: parse_qsl returns string values
# So we convert our original values to strings for a fair comparison
original_as_str = {k: str(v) for k, v in data.items()}
assert decoded == original_as_st
Nous pouvons maintenant exécuter notre test.
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_encoders.py
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item
test_encoders.py F
================================================= FAILURES =================================================
_______________________________________ test_for_nesting_limitation ________________________________________
@given(data=st.recursive(
> # Base case: A flat dictionary of text keys and simple values (text or integers).
^^^
st.dictionaries(st.text(), st.integers() | st.text()),
# Recursive step: Allow values to be dictionaries themselves.
lambda children: st.dictionaries(st.text(), children)
))
test_encoders.py:7:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
data = {'': {}}
@given(data=st.recursive(
# Base case: A flat dictionary of text keys and simple values (text or integers).
st.dictionaries(st.text(), st.integers() | st.text()),
# Recursive step: Allow values to be dictionaries themselves.
lambda children: st.dictionaries(st.text(), children)
))
def test_for_nesting_limitation(data):
"""
This test asserts that the decoded data structure matches the original.
It will fail because urlencode flattens nested structures.
"""
encoded = encode_dict_to_querystring(data)
decoded = decode_querystring_to_dict(encoded)
# This is a deliberately simple assertion. It will fail for nested
# dictionaries because the `decoded` version will have a stringified
# inner dict, while the `data` version will have a true inner dict.
# This is how we reveal the bug.
> assert decoded == data
E AssertionError: assert {'': '{}'} == {'': {}}
E
E Differing items:
E {'': '{}'} != {'': {}}
E Use -v to get more diff
E Falsifying example: test_for_nesting_limitation(
E data={'': {}},
E )
test_encoders.py:24: AssertionError
========================================= short test summary info ==========================================
FAILED test_encoders.py::test_for_nesting_limitation - AssertionError: assert {'': '{}'} == {'': {}}
Ok, c’était inattendu. Essayons de déchiffrer ce qui n’a pas fonctionné avec ce test. Le TL;DR est que ce test montre que les fonctions d’encodage/décodage ne fonctionnent pas correctement pour les dictionnaires imbriqués.
- L’exemple falsificateur. L’indice le plus important se trouve tout en bas. L’hypothèse nous dit le saisie exacte ça casse le code.
test_for_nesting_limitation(
data={'': {}},
)
- L’entrée est un dictionnaire où la clé est une chaîne vide et la valeur est un dictionnaire vide. Il s’agit d’un cas classique qu’un humain pourrait négliger.
- L’erreur d’assertion : Le test a échoué en raison de l’échec d’une instruction assert :
AssertionError: assert {'': '{}'} == {'': {}}
C’est là le cœur du problème. Les données originales utilisées dans le test étaient { » : {}}. Le résultat décodé issu de vos fonctions était { » : ‘{}’}. Cela montre que pour la clé », les valeurs sont différentes :
- En décodé, la valeur est la chaîne ‘{}’.
- Dans les données, la valeur est la dictionnaire {}.
Une chaîne n’est pas égale à un dictionnaire, donc l’assertion assert décodé == donnéesest FAUXet le test échoue.
Traçage du bug étape par étape
Notre fonction encode_dict_to_querystring utilise urllib.parse.urlencode. Lorsque urlencode voit une valeur qui est un dictionnaire (comme {}), il ne sait pas comment la gérer, il la convertit donc simplement en sa représentation sous forme de chaîne (‘{}’).
Les informations sur l’original de la valeurtaper (que c’était un dicté) c’est perdu pour toujours .
Lorsque la fonction decode_querystring_to_dict lit les données, elle décode correctement la valeur sous forme de chaîne « {} ». Il n’a aucun moyen de savoir qu’il s’agissait initialement d’un dictionnaire.
La solution : coder les valeurs imbriquées sous forme de chaînes JSON
La solution est simple,
- Encoder. Avant le codage URL, vérifiez chaque valeur dans votre dictionnaire. Si une valeur est un dictionnaire ou une liste, convertissez-la d’abord en chaîne JSON.
- Décoder. Après le décodage de l’URL, vérifiez chaque valeur. Si une valeur ressemble à une chaîne JSON (par exemple, commence par { ou [), parse it back into a Python object.
- Make our testing more comprehensive. Our given decorator is more complex. In simple terms, it tells Hypothesis to generate dictionaries that can contain other dictionaries as values, allowing for nested data structures of any depth. For example,
- A simple, flat dictionary: {‘name’: ‘Alice’, ‘city’: ‘London’}
- A one-level nested dictionary: {‘user’: {‘id’: ‘123’, ‘name’: ‘Tom’}}
- A two-level nested dictionary: {‘config’: {‘database’: {‘host’: ‘localhost’}}}
- And so on…
Here is the fixed code.
# test_encoders.py
from my_encoders import encode_dict_to_querystring, decode_querystring_to_dict
from hypothesis import given, strategies as st
# =========================================================================
# TEST 1: This test proves that the NESTING logic is correct.
# It uses a strategy that ONLY generates strings, so we don't have to
# worry about type conversion. This test will PASS.
# =========================================================================
@given(data=st.recursive(
st.dictionaries(st.text(), st.text()),
lambda children: st.dictionaries(st.text(), children)
))
def test_roundtrip_preserves_nested_structure(data):
"""Property: The encode/decode round-trip should preserve nested structures."""
encoded = encode_dict_to_querystring(data)
decoded = decode_querystring_to_dict(encoded)
assert decoded == data
# =========================================================================
# TEST 2: This test proves that the TYPE CONVERSION logic is correct
# for simple, FLAT dictionaries. This test will also PASS.
# =========================================================================
@given(data=st.dictionaries(st.text(), st.integers() | st.text()))
def test_roundtrip_stringifies_simple_values(data):
"""
Property: The round-trip should convert simple values (like ints)
to strings.
"""
encoded = encode_dict_to_querystring(data)
decoded = decode_querystring_to_dict(encoded)
# Create the model of what we expect: a dictionary with stringified values.
expected_data = {k: str(v) for k, v in data.items()}
assert decoded == expected_data
Now, if we rerun our test, we get this,
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item
test_encoders.py . [100%]
============================================ 1 passé en 0,16 s ==============================================
Ce que nous avons étudié là-bas est un exemple classique montrant à quel point les tests avec Hypothesis peuvent être utiles. Ce que nous pensions être deux fonctions simples et sans erreur s’est avéré ne pas être le cas.
Exemple de code 3 : Création d’une stratégie personnalisée pour un modèle Pydantic
De nombreuses fonctions du monde réel n’utilisent pas que de simples dictionnaires ; ils prennent des objets structurés comme les modèles Pydantic. Hypothesis peut également créer des stratégies pour ces types personnalisés.
Définissons un modèle dans my_models.py.
# my_models.py
from pydantic import BaseModel, Field
from typing import List
class Product(BaseModel):
id: int = Field(gt=0)
name: str = Field(min_length=1)
tags: List[str]
def calculate_shipping_cost(product: Product, weight_kg: float) -> float:
# A buggy shipping cost calculator
cost = 10.0 + (weight_kg * 1.5)
if "fragile" in product.tags:
cost *= 1.5 # Extra cost for fragile items
if weight_kg > 10:
cost += 20 # Surcharge for heavy items
# Bug: what if cost is negative?
return cost
Maintenant, dans test_shipping.py, nous allons construire une stratégie pour générer des instances de produit et tester notre fonction buggy.
# test_shipping.py
from my_models import Product, calculate_shipping_cost
from hypothesis import given, strategies as st
# Build a strategy for our Product model
product_strategy = st.builds(
Product,
id=st.integers(min_value=1),
name=st.text(min_size=1),
tags=st.lists(st.sampled_from(["electronics", "books", "fragile", "clothing"]))
)
@given(
product=product_strategy,
weight_kg=st.floats(min_value=-10, max_value=100, allow_nan=False, allow_infinity=False)
)
def test_shipping_cost_is_always_positive(product, weight_kg):
"""Property: The shipping cost should never be negative."""
cost = calculate_shipping_cost(product, weight_kg)
assert cost >= 0
Et le résultat du test ?
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_shipping.py
========================================================= test session starts ==========================================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item
test_shipping.py F
=============================================================== FAILURES ===============================================================
________________________________________________ test_shipping_cost_is_always_positive _________________________________________________
@given(
> product=product_strategy,
^^^
weight_kg=st.floats(min_value=-10, max_value=100, allow_nan=False, allow_infinity=False)
)
test_shipping.py:13:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
product = Product(id=1, name='0', tags=[]), weight_kg = -7.0
@given(
product=product_strategy,
weight_kg=st.floats(min_value=-10, max_value=100, allow_nan=False, allow_infinity=False)
)
def test_shipping_cost_is_always_positive(product, weight_kg):
"""Property: The shipping cost should never be negative."""
cost = calculate_shipping_cost(product, weight_kg)
> assert cost >= 0
E assert -0.5 >= 0
E Falsifying example: test_shipping_cost_is_always_positive(
E product=Product(id=1, name='0', tags=[]),
E weight_kg=-7.0,
E )
test_shipping.py:19: AssertionError
======================================================= short test summary info ========================================================
FAILED test_shipping.py::test_shipping_cost_is_always_positive - assert -0.5 >= 0
========================================================== 1 failed in 0.12s ===========================================================
Lorsque vous exécutez cela avec pytest, Hypothesis trouvera rapidement un exemple falsifiant : un produit avec un poids_kg négatif peut entraîner des frais d’expédition négatifs. Il s’agit d’un cas limite que nous n’aurions peut-être pas envisagé, mais Hypothesis l’a trouvé automatiquement.
Exemple de code 4 : Test des classes avec état
L’hypothèse peut faire plus que tester des fonctions pures. Il peut tester des classes avec un état interne en générant des séquences d’appels de méthodes pour tenter de les casser. Testons une simple classe LimitedCache personnalisée.
mon_cache.py
# my_cache.py
class LimitedCache:
def __init__(self, capacity: int):
if capacity <= 0:
raise ValueError("Capacity must be positive")
self._cache = {}
self._capacity = capacity
# Bug: This should probably be a deque or ordered dict for proper LRU
self._keys_in_order = []
def put(self, key, value):
if key not in self._cache and len(self._cache) >= self._capacity:
# Evict the oldest item
key_to_evict = self._keys_in_order.pop(0)
del self._cache[key_to_evict]
if key not in self._keys_in_order:
self._keys_in_order.append(key)
self._cache[key] = value
def get(self, key):
return self._cache.get(key)
@property
def size(self):
return len(self._cache)
Ce cache présente plusieurs bugs potentiels liés à sa politique d’expulsion. Testons-le à l’aide d’une machine à états basée sur des règles d’hypothèse, conçue pour tester des objets avec un état interne en générant des séquences aléatoires d’appels de méthode pour identifier les bogues qui n’apparaissent qu’après des interactions spécifiques.
Créez le fichier test_cache.py.
from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, rule, precondition
from my_cache import LimitedCache
class CacheMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.cache = LimitedCache(capacity=3)
# This rule adds 3 initial items to fill the cache
@rule(
k1=st.just('a'), k2=st.just('b'), k3=st.just('c'),
v1=st.integers(), v2=st.integers(), v3=st.integers()
)
def fill_cache(self, k1, v1, k2, v2, k3, v3):
self.cache.put(k1, v1)
self.cache.put(k2, v2)
self.cache.put(k3, v3)
# This rule can only run AFTER the cache has been filled.
# It tests the core logic of LRU vs FIFO.
@precondition(lambda self: self.cache.size == 3)
@rule()
def test_update_behavior(self):
"""
Property: Updating the oldest item ('a') should make it the newest,
so the next eviction should remove the second-oldest item ('b').
Our buggy FIFO cache will incorrectly remove 'a' anyway.
"""
# At this point, keys_in_order is ['a', 'b', 'c'].
# 'a' is the oldest.
# We "use" 'a' again by updating it. In a proper LRU cache,
# this would make 'a' the most recently used item.
self.cache.put('a', 999)
# Now, we add a new key, which should force an eviction.
self.cache.put('d', 4)
# A correct LRU cache would evict 'b'.
# Our buggy FIFO cache will evict 'a'.
# This assertion checks the state of 'a'.
# In our buggy cache, get('a') will be None, so this will fail.
assert self.cache.get('a') is not None, "Item 'a' was incorrectly evicted"
# This tells pytest to run the state machine test
TestCache = CacheMachine.TestCase
L’hypothèse générera de longues séquences de put et de get. Il identifiera rapidement une séquence de put qui fait que la taille du cache dépasse sa capacité ou que son expulsion se comporte différemment de notre modèle, révélant ainsi des bugs dans notre implémentation.
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_cache.py
========================================================= test session starts ==========================================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item
test_cache.py F
=============================================================== FAILURES ===============================================================
__________________________________________________________ TestCache.runTest ___________________________________________________________
self = <hypothesis.stateful.CacheMachine.TestCase testMethod=runTest>
def runTest(self):
> run_state_machine_as_test(cls, settings=self.settings)
../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:476:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:258: in run_state_machine_as_test
state_machine_test(state_machine_factory)
../hyp-env/lib/python3.11/site-packages/hypothesis/stateful.py:115: in run_state_machine
@given(st.data())
^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = CacheMachine({})
@precondition(lambda self: self.cache.size == 3)
@rule()
def test_update_behavior(self):
"""
Property: Updating the oldest item ('a') should make it the newest,
so the next eviction should remove the second-oldest item ('b').
Our buggy FIFO cache will incorrectly remove 'a' anyway.
"""
# At this point, keys_in_order is ['a', 'b', 'c'].
# 'a' is the oldest.
# We "use" 'a' again by updating it. In a proper LRU cache,
# this would make 'a' the most recently used item.
self.cache.put('a', 999)
# Now, we add a new key, which should force an eviction.
self.cache.put('d', 4)
# A correct LRU cache would evict 'b'.
# Our buggy FIFO cache will evict 'a'.
# This assertion checks the state of 'a'.
# In our buggy cache, get('a') will be None, so this will fail.
> assert self.cache.get('a') is not None, "Item 'a' was incorrectly evicted"
E AssertionError: Item 'a' was incorrectly evicted
E assert None is not None
E + where None = get('a')
E + where get = <my_cache.LimitedCache object at 0x7f0debd1da90>.get
E + where <my_cache.LimitedCache object at 0x7f0debd1da90> = CacheMachine({}).cache
E Falsifying example:
E state = CacheMachine()
E state.fill_cache(k1='a', k2='b', k3='c', v1=0, v2=0, v3=0)
E state.test_update_behavior()
E state.teardown()
test_cache.py:44: AssertionError
======================================================= short test summary info ========================================================
FAILED test_cache.py::TestCache::runTest - AssertionError: Item 'a' was incorrectly evicted
========================================================== 1 failed in 0.20s ===========================================================
La sortie ci-dessus met en évidence un bug dans le code. En termes simples, cette sortie montre que le cache est pas un bon « Les moins récemment utilisés » (LRU) cache. Il présente le défaut important suivant,
Lorsque vous mettez à jour un élément qui est déjà dans le cache, le cache ne se souvient pas qu’il s’agit désormais de l’élément « le plus récent ». Il le traite toujours comme le plus ancien, il est donc expulsé (expulsé) prématurément du cache.
Exemple de code 5 – Test par rapport à une implémentation de référence plus simple
Pour notre dernier exemple, nous examinerons une situation typique. Souvent, les codeurs écrivent des fonctions censées remplacer des fonctions plus anciennes, plus lentes, mais par ailleurs parfaitement correctes. Votre nouvelle fonction doit avoir les mêmes sorties que l’ancienne fonction pour les mêmes entrées. L’hypothèse peut rendre vos tests à cet égard beaucoup plus faciles.
Disons que nous avons une fonction simple, sum_list_simple, et une nouvelle, « optimisé » sum_list_fast qui a un bug.
mes_sommes.py
# my_sums.py
def sum_list_simple(data: list[int]) -> int:
# This is our simple, correct reference implementation
return sum(data)
def sum_list_fast(data: list[int]) -> int:
# A new "fast" implementation with a bug (e.g., integer overflow for large numbers)
# or in this case, a simple mistake.
total = 0
for x in data:
# Bug: This should be +=
total = x
return total
test_sums.py
# test_sums.py
from my_sums import sum_list_simple, sum_list_fast
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_fast_sum_matches_simple_sum(data):
"""
Property: The result of the new, fast function should always match
the result of the simple, reference function.
"""
assert sum_list_fast(data) == sum_list_simple(data)
L’hypothèse découvrira rapidement que pour toute liste comportant plus d’un élément, la nouvelle fonction échoue. Vérifions ça.
(hyp-env) (base) tom@tpr-desktop:~/hypothesis_project$ pytest -s test_my_sums.py
=========================================== test session starts ============================================
platform linux -- Python 3.11.10, pytest-8.4.0, pluggy-1.6.0
rootdir: /home/tom/hypothesis_project
plugins: hypothesis-6.135.9, anyio-4.9.0
collected 1 item
test_my_sums.py F
================================================= FAILURES =================================================
_____________________________________ test_fast_sum_matches_simple_sum _____________________________________
@given(st.lists(st.integers()))
> def test_fast_sum_matches_simple_sum(data):
^^^
test_my_sums.py:6:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
data = [1, 0]
@given(st.lists(st.integers()))
def test_fast_sum_matches_simple_sum(data):
"""
Property: The result of the new, fast function should always match
the result of the simple, reference function.
"""
> assert sum_list_fast(data) == sum_list_simple(data)
E assert 0 == 1
E + where 0 = sum_list_fast([1, 0])
E + and 1 = sum_list_simple([1, 0])
E Falsifying example: test_fast_sum_matches_simple_sum(
E data=[1, 0],
E )
test_my_sums.py:11: AssertionError
========================================= short test summary info ==========================================
FAILED test_my_sums.py::test_fast_sum_matches_simple_sum - assert 0 == 1
============================================ 1 failed in 0.17s =============================================
Le test a donc échoué parce que « rapide » la fonction somme a donné la mauvaise réponse (0) pour la liste d’entrée [1, 0]tandis que la bonne réponse, fournie par le « simple »fonction sum, était 1. Maintenant que vous connaissez le problème, vous pouvez prendre des mesures pour le résoudre.
Résumé
Dans cet article, nous avons plongé en profondeur dans le monde des tests basés sur les propriétés avec Hypothesis, allant au-delà de simples exemples pour montrer comment il peut être appliqué aux défis de tests du monde réel. Nous avons vu qu’en définissant les invariants de notre code, nous pouvons découvrir des bugs subtils que les tests traditionnels manqueraient probablement. Nous avons appris à :
- Testez la propriété « aller-retour » et voyez comment des stratégies de données plus complexes peuvent révéler les limites de notre code.
- Créez des stratégies personnalisées pour générer des instances de modèles Pydantic complexes pour tester la logique métier.
- Utilisez une RuleBasedStateMachine pour tester le comportement des classes avec état en générant des séquences d’appels de méthode.
- Validez une fonction complexe et optimisée en la testant par rapport à une implémentation de référence plus simple et connue.
L’ajout de tests basés sur les propriétés à votre boîte à outils ne remplacera pas tous vos tests existants. Néanmoins, cela les augmentera profondément, vous obligeant à réfléchir plus clairement aux contrats de votre code et vous donnant un degré de confiance beaucoup plus élevé quant à son exactitude. Je vous encourage à choisir une fonction ou une classe dans votre base de code, à réfléchir à ses propriétés fondamentales et à laisser Hypothesis faire de son mieux pour vous prouver le contraire. Vous en serez un meilleur développeur.
Je n’ai fait qu’effleurer la surface de ce que Hypothesis peut faire pour vos tests. Pour plus d’informations, reportez-vous à leur documentation officielle, disponible via le lien ci-dessous.



