Article 5 de la série “De la spécification à l’exécution”. L’article précédent refermait sur une question simple. Qu’est-ce qui crée la force d’une suite de tests ? Cet article y répond avec un outil que je trouve particulièrement utile.

En 2025, une équipe de chercheurs a branché un agent IA équipé d’Hypothesis sur 100 packages Python populaires. Après triage, ils ont obtenu 984 bug reports, dont 56 % de vrais bugs. Plusieurs corrections ont déjà été mergées dans NumPy, AWS Lambda Powertools, Hugging Face Tokenizers, CloudFormation CLI et python-dateutil. Le point important est simple. Les bugs étaient dans les bords. Étude détaillée : Agentic Property-Based Testing: Finding Bugs Across the Python Ecosystem.

Le property-based testing, ou PBT, est l’outil qui trouve ce genre de bugs. Je vais montrer six patterns canoniques, le stateful PBT pour les bugs de séquence, et la place qu’un agent IA peut prendre dans ce travail.

Pourquoi les tests par exemples ratent les bords

Quand un développeur écrit des tests par exemples, il pense en général à quelques cas. Trois, cinq, parfois dix. Le chemin nominal d’abord, quelques échecs si la discipline est bonne. Mais combien de tests couvrent une chaîne de 100 000 caractères ? Un caractère surrogate Unicode ? Une liste vide passée à une fonction qui n’a jamais vu ce cas ? Un flottant juste à la frontière de la précision IEEE 754 ?

L’idée qu’un agent IA corrige ce problème n’est pas plus solide. L’IA hérite souvent des mêmes biais que les humains qui ont produit son corpus. Les exemples qu’elle propose sont, statistiquement, ceux auxquels les humains pensent déjà. L’étude Agentic PBT le montre bien. random.wald() de NumPy renvoyait silencieusement des valeurs négatives. slice_dictionary d’AWS Lambda Powertools renvoyait le premier chunk plusieurs fois au lieu d’avancer. Personne, humain ou IA, n’avait écrit le test qui aurait visé ces cas.

Le PBT inverse la logique. Je ne génère pas des exemples à la main. Je déclare une propriété, un invariant, une relation, une équation qui doit toujours tenir. Le framework génère ensuite des centaines, parfois des milliers d’exemples.

Le bonus utile, c’est le shrinking. Quand un échec apparaît, le framework cherche le plus petit contre-exemple qui reproduit le bug. Je n’obtiens pas un échec impossible à lire sur une chaîne de 50 000 caractères. J’obtiens souvent un cas minimal, parfois un seul caractère. Le diagnostic devient immédiat.

Les six patterns canoniques

Je garde six patterns en tête. Ils couvrent l’essentiel des cas utiles.

1. Invariant, quelque chose qui doit toujours tenir

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sort_preserves_length(xs):
    assert len(sorted(xs)) == len(xs)

Tri préserve la longueur. C’est trivial à formuler, mais utile en pratique. Je l’emploie pour ce qui ne doit pas changer, longueur, somme, ensemble d’éléments, structure.

2. Round-trip, encode/decode, serialize/parse

@given(st.dictionaries(st.text(), st.integers()))
def test_json_roundtrip(d):
    assert json.loads(json.dumps(d)) == d

C’est le pattern le plus rentable pour les parseurs et les sérialiseurs. Dans le contexte télécom, il révèle très vite les bugs SIP, SDP et RTP. La démo examples/pbt-sip/ du repo public illustre ce pattern sur un parseur SIP minimaliste.

3. Idempotence, f(f(x)) == f(x)

@given(st.text())
def test_normalize_idempotent(s):
    assert normalize(normalize(s)) == normalize(s)

Je l’utilise pour une fonction qui doit rester stable après une première application, comme normalize, dedupe, sort ou canonicalize. Ce pattern attrape vite les fonctions qui ajoutent quelque chose au premier passage puis le retirent au second.

4. Métamorphique, relation entre deux exécutions

@given(st.lists(st.integers()), st.integers())
def test_appending_increases_length_by_one(xs, x):
    assert len(xs + [x]) == len(xs) + 1

Je l’utilise quand je ne peux pas écrire un oracle direct. Le pattern a été formalisé par T. Y. Chen en 1998, et il reste utile pour les algorithmes complexes, les optimiseurs et les simulations. Dans un dialplan télécom, je peux par exemple vérifier qu’ajouter une route plus spécifique ne change pas le coût pour les numéros déjà couverts.

5. Oracle alternatif, comparer à un modèle simple connu correct

@given(st.lists(st.integers()))
def test_my_fast_sort_matches_python(xs):
    assert my_fast_sort(xs) == sorted(xs)

Je l’emploie quand j’ai une implémentation rapide à valider contre une version simple supposée correcte. C’est très utile pour des structures de données complexes, comme les skiplists, les B-trees ou les tries.

6. Algébrique, commutativité, associativité, distributivité

@given(st.integers(), st.integers())
def test_add_commutative(a, b):
    assert add(a, b) == add(b, a)

Je l’utilise pour les agrégations, les merges et les réductions distribuées. Si une opération est censée être commutative, le PBT peut le vérifier sur des milliers de paires.

Stateful PBT, l’arme contre les bugs de séquence

Le PBT classique teste des fonctions. Le stateful PBT teste des séquences d’opérations sur un système qui garde un état.

C’est là que se cachent beaucoup de bugs subtils. Ils n’apparaissent qu’après une série d’actions dans un ordre particulier.

Je prends un exemple simple sur un dialog SIP. Le code complet est dans examples/pbt-sip/ du repo public.

from hypothesis.stateful import RuleBasedStateMachine, rule, invariant

class DialogMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.dialog = Dialog(call_id="test-call")
        self.model_state = State.EARLY

    @rule()
    def receive_2xx(self):
        self.dialog.receive_2xx()
        self.model_state = State.CONFIRMED

    @rule()
    def receive_cancel(self):
        self.dialog.receive_cancel()

    @invariant()
    def state_matches_model(self):
        assert self.dialog.state is self.model_state

Hypothesis génère des séquences arbitraires de receive_2xx, receive_cancel, et autres transitions, puis vérifie l’invariant après chaque étape. Sur ce code, le framework trouve en moins de trente millisecondes un bug RFC 3261 §9.2. Un CANCEL passe après la réponse finale en seulement deux transitions. C’est exactement le genre de séquence qu’un test example-based n’écrit jamais.

Je garde quelques domaines de prédilection pour ce mode de test.

  • FSM télécom, dialog SIP, états RTP, dialplan routing, sessions SBC.
  • Reducers, Redux, Zustand, useReducer côté React.
  • Services stateful, paniers d’achat, files d’attente, sessions utilisateur.
  • Toute API où l’ordre des appels compte.

Pourquoi le PBT se prête bien à l’IA

Je vois trois raisons.

1. Les LLM proposent assez bien des propriétés

Les modèles savent partir d’une signature, d’un docstring ou de noms de fonction pour proposer des invariants utiles. Anthropic Red le dit clairement dans Property-Based Testing with Claude (2026). Ils identifient bien des garanties sémantiques si le texte d’entrée est clair.

2. L’oracle du PBT est une relation

L’oracle n’est pas une valeur isolée. C’est une relation entre entrées et sorties, ou entre deux exécutions. Comme je l’ai écrit dans l’article 3, cela réduit la place pour les tautologies. Je n’ai pas besoin d’inventer une valeur de référence. J’énonce une règle.

3. La boucle de réflexion filtre les faux positifs

Le reflection loop documenté par Anthropic Red vérifie que l’agent n’a pas accidentellement masqué l’erreur avec un try/except, ou qu’il n’a pas écrit une propriété trop forte. D’après l’étude arxiv 2510.09907, cette boucle fait passer la validité des rapports de 56 % à 81 % pour les rapports les mieux scorés.

Le schéma d’usage me paraît simple.

  1. L’agent propose trois à cinq propriétés candidates, avec la phrase ou la signature qui les justifie.
  2. Je choisis la propriété à implémenter.
  3. L’agent écrit le test Hypothesis.
  4. La reflection loop tourne.
  5. Le résultat passe en code review.

L’étude Agentic PBT a aussi quantifié le coût total, 9,93 USD par bug validé, en coût d’inférence Claude/GPT-4. Ce n’est pas une vérité universelle, mais c’est un ordre de grandeur utile.

Trois propriétés à ajouter cette semaine

Je peux commencer petit. Trois patterns donnent déjà beaucoup de valeur.

Sur un parseur, le round-trip

@given(any_input_strategy())
def test_parse_format_roundtrip(x):
    assert parse(format(x)) == x

Si j’ai un parseur ou un sérialiseur quelque part, je peux écrire ce test. Il faut peu de lignes et il révèle souvent des bugs très coûteux.

Sur un calcul, un invariant numérique

@given(positive_floats(), positive_floats())
def test_total_is_at_least_each_component(a, b):
    t = total(a, b)
    assert t >= a and t >= b

Je l’utilise pour toute fonction qui agrège ou compose des valeurs. Pour un rating d’appel télécom, je peux vérifier que le coût total est supérieur ou égal au coût de chaque sous-période.

Sur une fonction pure, l’idempotence

@given(st.lists(st.integers()))
def test_dedupe_idempotent(xs):
    assert dedupe(dedupe(xs)) == dedupe(xs)

Je l’emploie pour une fonction qui prétend nettoyer, normaliser ou canoniser. Le second appel doit être un no-op.

Outils par stack

LangageOutil
PythonHypothesis
TypeScript / JavaScriptfast-check
Gorapid
Rustproptest

Hypothesis a aussi un bonus déterministe. hypothesis write mon_module.parse_uri génère un squelette de test PBT à partir de la signature et du docstring, sans IA. C’est un outil que je trouve encore trop peu utilisé.

Trois points à retenir

1. Les tests par exemples ratent les bords. Humains et IA partagent cette limite. Générer plus de cas ne corrige pas le problème de fond.

2. Le stateful PBT (RuleBasedStateMachine dans Hypothesis) trouve les bugs de séquence que les tests par exemples ne couvrent pas.

3. Le PBT se prête très bien à un usage assisté par IA. La propriété devient une relation, pas une valeur, et cela limite les tautologies.

Avec la spec, la répartition des rôles, les axes de mesure et le PBT, le workflow est maintenant posé. La semaine prochaine, je passe à une mise en pratique sur un stack moderne, React + Go, avec un boilerplate runnable.


Pour aller plus loin


Cet article est le cinquième d’une série de sept. L’article précédent traitait des axes de mesure de la qualité. Le prochain parlera de la mise en pratique sur un stack React + Go (Gin / GORM / PostgreSQL).