Aller au contenu

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 :

def test_tri():
    assert sort([3, 1, 2]) == [1, 2, 3]
    assert sort([]) == []
    assert sort([1]) == [1]

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