Property-Based Testing¶
Tester des propriétés plutôt que des exemples — générer des milliers de cas automatiquement.
Le problème des tests par exemples¶
Les tests classiques verifient des cas spécifiques choisis par le développeur :
Le problème : vous ne testez que les cas auxquels vous pensez. Les bugs les plus vicieux se cachent dans les cas auxquels personne n'a pense.
L'approche property-based¶
Au lieu de définir des exemples, vous definissez des propriétés qui doivent être vraies pour toute entree valide. Le framework généré automatiquement des centaines d'entrees aléatoires.
graph LR
A["Definir une propriete"] --> B["Le framework genere<br/>des entrees aleatoires"]
B --> C["Verifier la propriete<br/>sur chaque entree"]
C -->|"propriete violee"| D["Shrinking<br/>trouver l'entree minimale"]
C -->|"toutes OK"| E["Propriete valide"] Exemple : propriétés d'une fonction de tri¶
from hypothesis import given
import hypothesis.strategies as st
@given(st.lists(st.integers()))
def test_sort_preserve_length(xs):
"""Le tri ne perd ni n'ajoute d'elements."""
assert len(sort(xs)) == len(xs)
@given(st.lists(st.integers()))
def test_sort_is_ordered(xs):
"""Le resultat est ordonne."""
result = sort(xs)
for i in range(len(result) - 1):
assert result[i] <= result[i + 1]
@given(st.lists(st.integers()))
def test_sort_same_elements(xs):
"""Le tri contient exactement les memes elements."""
assert sorted(sort(xs)) == sorted(xs)
Hypothesis va générer des listes vides, des listes avec des doublons, des listes avec des nombres negatifs, des listes énormes — des cas que vous n'auriez probablement pas écrits à la main.
Les propriétés courantes¶
| Propriété | Description | Exemple |
|---|---|---|
| Aller-retour (roundtrip) | decode(encode(x)) == x | JSON, serialisation, compression |
| Idempotence | f(f(x)) == f(x) | Formatage, normalisation |
| Invariant | Une propriété reste vraie après l'opération | Tri : même longueur, même éléments |
| Équivalence | Deux implémentations donnent le même résultat | Optimisation vs référence |
| Commutativite | f(a, b) == f(b, a) | Addition, union d'ensembles |
Exemple : roundtrip JSON¶
from hypothesis import given
import hypothesis.strategies as st
import json
@given(st.dictionaries(st.text(), st.integers()))
def test_json_roundtrip(data):
"""Encoder puis decoder retourne les memes donnees."""
assert json.loads(json.dumps(data)) == data
Exemple : idempotence d'un formatter¶
@given(st.text())
def test_formatter_idempotent(code):
"""Formater deux fois donne le meme resultat que formater une fois."""
assert format_code(format_code(code)) == format_code(code)
Shrinking¶
Quand le framework trouve une entree qui viole la propriété, il la réduit (shrink) pour trouver l'entree minimale qui reproduit le bug.
# Entree qui echoue (trouvee par generation)
[347, -29, 0, 1000003, -1, 42, 7, -500]
# Apres shrinking (entree minimale)
[1, 0]
Le shrinking transforme un bug cryptique en un cas minimal reproductible. C'est l'une des forces majeures du property-based testing.
Quand utiliser le property-based testing¶
| Situation | PBT adapté ? |
|---|---|
| Fonctions pures avec des propriétés mathématiques | Oui |
| Serialisation / deserialisation | Oui |
| Parsers | Oui |
| Structures de données (collections, arbres) | Oui |
| Code avec beaucoup d'effets de bord | Difficile |
| UI / intégration | Non |
| Code dependant d'un état externe | Difficile |
PBT en complement
Le property-based testing ne remplacé pas les tests par exemples — il les complète. Gardez vos tests par exemples pour documenter les cas importants, et ajoutez du PBT pour découvrir les cas que vous n'avez pas imagines.
Stratégies de génération¶
Les frameworks PBT fournissent des stratégies pour générer des données typees :
from hypothesis import strategies as st
# Types simples
st.integers() # entiers
st.floats(allow_nan=False) # flottants sans NaN
st.text(min_size=1, max_size=100)# chaines non vides
# Collections
st.lists(st.integers(), min_size=0, max_size=50)
st.dictionaries(st.text(), st.integers())
# Composition
st.tuples(st.text(), st.integers())
st.one_of(st.none(), st.integers()) # Optional[int]
# Strategies custom
@st.composite
def email_strategy(draw):
local = draw(st.text(
alphabet=st.characters(whitelist_categories=('L', 'N')),
min_size=1, max_size=20
))
domain = draw(st.text(
alphabet=st.characters(whitelist_categories=('L',)),
min_size=2, max_size=10
))
tld = draw(st.sampled_from(["com", "org", "fr", "dev"]))
return f"{local}@{domain}.{tld}"
Outils¶
| Outil | Langage | Lien |
|---|---|---|
| Hypothesis | Python | hypothesis.readthedocs.io |
| fast-check | JavaScript | github.com/dubzzz/fast-check |
| QuickCheck | Haskell | hackage.haskell.org/package/QuickCheck |
| jqwik | Java | jqwik.net |
| gopter | Go | github.com/leanovate/gopter |
| proptest | Rust | github.com/proptest-rs/proptest |