100 exécutions de tests, c’est un mensonge : comment dimensionner vos tests par propriétés
Si vous exécutez des tests par propriétés avec les 100 exemples par défaut, vous avez le pire des deux mondes. Votre CI est plus lente que nécessaire, et vous ne détectez toujours pas les bugs qui comptent.
Ce nombre n’a rien de magique. La plupart des bibliothèques, Hypothesis y compris, utilisent 100 par défaut parce que c’est un nombre rond qui donne une impression de sécurité. Mais « donner une impression de sécurité » n’est pas une stratégie de test.
Ce que les tests par propriétés promettent réellement
Les tests par propriétés renversent la logique des tests unitaires. Au lieu d’écrire manuellement les entrées et les sorties attendues, vous définissez une propriété. Une règle qui doit toujours tenir. Le framework génère des entrées pour la briser.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
assert lst == list(reversed(list(reversed(lst))))
Le framework exécute cette fonction de nombreuses fois avec des listes aléatoires d’entiers. S’il trouve un contre-exemple, il réduit l’entrée à la plus petite version qui échoue encore. Une liste de 47 éléments qui déclenche un bug est inutile pour le débogage. Une liste de 3 éléments, c’est de l’or.
C’est puissant. C’est aussi probabiliste. Les tests par propriétés ne peuvent pas prouver la correction. Ils ne peuvent qu’accroître votre confiance dans le fait qu’un bug n’existe pas, ou trouver un bug s’il en existe un. Cette nature probabiliste est ce qui rend le nombre d’exécutions si important.
Pourquoi 100 est arbitraire
Soyons honnêtes sur l’origine de ce 100. Dans Hypothesis, c’est une valeur par défaut choisie en 2015 parce que c’était un nombre rond sympathique qui attrapait la plupart des bugs sans rendre les tests insupportablement lents. C’était un compromis social, pas statistique.
La probabilité de trouver un bug dépend de deux choses. De la fréquence du bug dans l’espace des entrées, et du nombre d’échantillons que vous prenez. Si un bug ne se déclenche que lorsque l’entrée est un palindrome de longueur supérieure à 20, et que les palindromes représentent 0,01 % de toutes les listes, 100 exécutions vous donnent environ 1 % de chances de le détecter. Ce n’est pas un test. C’est un billet de loterie.
La plupart des bugs ne sont pas si rares. Beaucoup de propriétés cassent sur des listes vides, des éléments uniques, ou de simples doublons. Un générateur bien réglé les détecte rapidement. Mais la valeur par défaut de 100 suppose que vos générateurs sont parfaits et que vos bugs sont superficiels. Les deux hypothèses sont fausses.
Ce qu’achète vraiment le nombre d’exécutions, statistiquement parlant
Si nous modélisons la découverte d’un bug comme un échantillonnage avec remise dans un espace d’entrées où le bug a une probabilité p d’apparaître, la probabilité de manquer le bug après n exécutions est (1 - p)^n.
Pour p = 0,01, 100 exécutions vous donnent 37 % de chances de manquer le bug. Pour p = 0,001, 100 exécutions vous donnent 90 % de chances de le manquer. Pour obtenir une confiance de 99 % de détecter un bug à 0,1 %, il faut environ 4 600 exécutions.
import math
def runs_for_confidence(p, confidence=0.99):
"""Returns the runs needed to catch a bug with probability `p`
at the given confidence level."""
return math.ceil(math.log(1 - confidence) / math.log(1 - p))
print(runs_for_confidence(0.01)) # 459
print(runs_for_confidence(0.001)) # 4603
print(runs_for_confidence(0.0001)) # 46050
C’est la partie qui met les gens mal à l’aise. Si vous voulez une forte confiance dans les bugs rares, il vous faut des dizaines de milliers d’exécutions. Personne ne veut attendre aussi longtemps en CI.
Le shrinking change l’équation du coût
La valeur par défaut de 100 exécutions a été fixée avant que le shrinking soit aussi performant qu’aujourd’hui. Les frameworks modernes de tests par propriétés ne se contentent pas de trouver des bugs. Ils trouvent des bugs minimaux.
Cela signifie que vous pouvez penser en termes de budget, pas seulement de nombre. Si vous exécutez 1 000 exemples et trouvez un bug à la 847e exécution, le shrinking peut prendre encore 200 à 300 exécutions pour minimiser le contre-exemple. Le coût total est de 1 100 exécutions ou plus pour un seul bug. Mais si vous exécutez 10 000 exemples et ne trouvez rien, vous avez dépensé 10 000 exécutions pour votre tranquillité d’esprit.
L’astuce consiste à séparer la découverte de la validation. Exécutez une petite suite rapide en CI pour un retour immédiat. Exécutez une suite plus large et plus lente de nuit ou sur les branches de release pour une confiance plus profonde.
from hypothesis import given, settings, strategies as st
import json
# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
assert json.loads(json.dumps(d)) == d
# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
assert json.loads(json.dumps(d)) == d
Ce n’est pas seulement une question de vitesse. C’est une question de densité d’information. Un test de 100 exécutions qui passe ne vous dit presque rien. Un test de 5 000 exécutions qui passe vous en dit légèrement plus. Un test de 100 exécutions qui échoue vous dit exactement où chercher.
Comment nous divisons les tests par propriétés en niveaux
D’après notre expérience, la meilleure approche consiste à cesser de traiter toutes les propriétés comme égales. Nous les divisons en trois niveaux.
Les propriétés rapides s’exécutent à chaque pull request. Ce sont les propriétés mécaniques. La sérialisation aller-retour, l’idempotence de la déduplication, les invariants de base sur les structures de données. Nous exécutons 100 à 200 exemples. Ils se terminent en moins d’une seconde.
Les propriétés profondes s’exécutent à chaque fusion dans main. Elles ciblent les state machines complexes, les pipelines de traitement d’événements, et tout ce qui implique une explosion combinatoire. Nous exécutons 2 000 à 10 000 exemples. Elles prennent des minutes, pas des heures.
Les propriétés exploratoires s’exécutent manuellement avant les releases. Ce sont celles où nous poussons max_examples à 50 000 ou plus et laissons la machine tourner pendant que nous relisons le changelog. Nous avons trouvé de cette manière des race conditions et des cas limites de dépassement d’entier qu’aucune quantité de tests unitaires n’aurait détectés.
Quoi faire au lieu de deviner
Cessez de traiter max_examples comme un bouton que vous réglez une fois et oubliez. Traitez-le comme une configuration qui appartient à la propriété, pas au framework.
Posez-vous trois questions pour chaque propriété que vous écrivez.
Combien coûte ce test à exécuter ? Si chaque exemple prend 50 ms, 10 000 exécutions font 8 minutes. S’il prend 5 ms, c’est moins d’une minute.
À quel point le bug est-il grave si nous le manquons ? Un bug de formatage dans un message de log n’est pas la même chose qu’un bug de corruption de données dans un pipeline de paiement.
À quel point la condition de déclenchement est-elle rare ? Si le bug n’apparaît que les années bissextiles, ou lorsque deux UUID entrent en collision, ou exactement à INT_MAX, il vous faut plus d’exécutions ou un générateur plus malin.
Des générateurs plus malins battent presque toujours plus d’exécutions. Si vous testez un parser JSON, ne générez pas de chaînes aléatoires en espérant qu’elles seront valides. Générez des objets valides, puis mutez-les.
from hypothesis import given, settings, strategies as st
import json
# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
try:
json.loads(s)
except json.JSONDecodeError:
pass # Most inputs hit this immediately
# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
assert json.loads(json.dumps(d)) == d
500 exécutions avec un bon générateur battent 10 000 exécutions avec un mauvais. À chaque fois.
Questions fréquentes sur le dimensionnement des tests par propriétés
Plus d’exécutions ne signifie-t-il pas toujours une meilleure couverture ?
Pas exactement. Les tests par propriétés n’ont pas de métrique de couverture au sens traditionnel. Plus d’exécutions augmentent la probabilité de trouver un bug, mais les rendements décroissants s’installent rapidement. Doubler de 100 à 200 exécutions est significatif. Doubler de 10 000 à 20 000 l’est rarement.
Et le fuzzing ? N’est-ce pas simplement du property-based testing avec des millions d’exécutions ?
Le fuzzing est adjacent mais différent. Les fuzzers exécutent typiquement des millions d’entrées sans compréhension sémantique du domaine. Les tests par propriétés utilisent des générateurs structurés et le shrinking. Vous pouvez considérer le PBT comme du fuzzing intelligent, ou le fuzzing comme du PBT par force brute. Le calcul du nombre d’exécutions est différent car le coût par exécution et l’information par exécution ne sont pas les mêmes.
Dois-je régler max_examples plus haut pour la CI ou plus bas ?
Plus haut pour la CI, plus bas pour le développement local. Votre ordinateur portable est pour la vitesse. Votre CI est pour la confiance. Utilisez un profil de paramètres ou une variable d’environnement pour basculer entre les deux.
import os
from hypothesis import settings
CI = os.environ.get("CI", "false").lower() == "true"
settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)
settings.load_profile("ci" if CI else "dev")
Comment savoir si mon générateur est assez bon ?
Exécutez votre test avec max_examples réglé très haut, disons 50 000, et observez le rapport de couverture. Si des branches manquent, votre générateur ne les exerce pas. Corrigez le générateur avant de diminuer le nombre d’exécutions.
Cessez de chercher le nombre d’exécutions parfait et commencez à mesurer
Il n’existe pas de nombre universellement correct d’exécutions de tests pour les tests par propriétés. Il n’y a que le bon nombre pour votre propriété, vos générateurs, votre budget CI, et le coût du bug que vous essayez de prévenir.
Commencez par 100 si vous devez. Mais augmentez-le pour les propriétés qui protègent les chemins critiques, et diminuez-le pour les propriétés qui ne sont que des vérifications de santé. Mesurez le temps que prennent vos tests. Profilez vos générateurs. Et rappelez-vous : un test par propriétés qui passe 100 fois n’est pas une preuve. C’est juste une indication.
Si vous voulez creuser davantage, la documentation d’Hypothesis sur les statistiques de test et le property-based testing ciblé vaut la peine d’être lue. La CLI hypothesis peut vous montrer exactement sur quels exemples vos tests passent du temps. C’est le premier endroit à regarder lorsque vous décidez d’augmenter ou de diminuer le nombre.