100 Test Runs Es una Mentira: Cómo Dimensionar Correctamente Tus Property-Based Tests

Si ejecutas property-based tests con los 100 ejemplos por defecto, te estás llevando lo peor de ambos mundos. Tu CI es más lenta de lo necesario, y aún así no estás detectando los bugs que importan.

El número no es magia. La mayoría de las librerías, Hypothesis incluida, usan 100 como valor por defecto porque es un número redondo que resulta reconfortante. Pero «sentirse seguro» no es una estrategia de testing.

Qué Promete Realmente el Property-Based Testing

El property-based testing le da la vuelta a los unit tests. En lugar de escribir inputs y outputs esperados a mano, defines una property. Una regla que siempre debería cumplirse. El framework genera inputs para romperla.

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))))

El framework ejecuta esta función muchas veces con listas aleatorias de enteros. Si encuentra un counterexample, realiza shrinking sobre el input hasta obtener la versión más pequeña que sigue fallando. Una lista de 47 elementos que activa un bug es inútil para depurar. Una lista de 3 elementos es oro.

Esto es potente. También es probabilístico. El property-based testing no puede probar la corrección. Solo puede aumentar tu confianza de que un bug no existe, o encontrar un bug si lo hay. Esa naturaleza probabilística es lo que hace que el número de ejecuciones sea tan importante.

Por Qué 100 es Arbitrario

Seamos honestos sobre de dónde viene el 100. En Hypothesis, es un valor por defecto elegido en 2015 porque era un número redondo agradable que detectaba la mayoría de los bugs sin hacer los tests insoportablemente lentos. Fue un compromiso social, no uno estadístico.

La probabilidad de encontrar un bug depende de dos cosas. De cuán común es el bug en el espacio de inputs, y de cuántas muestras tomas. Si un bug solo se activa cuando el input es un palíndromo de longitud mayor a 20, y los palíndromos representan el 0,01% de todas las listas, 100 ejecuciones te dan aproximadamente un 1% de probabilidad de detectarlo. Eso no es un test. Es un billete de lotería.

La mayoría de los bugs no son tan raros. Muchas properties fallan con listas vacías, elementos únicos o duplicados simples. Un generator bien afinado detecta esos casos rápidamente. Pero el valor por defecto de 100 asume que tus generators son perfectos y que tus bugs son superficiales. Ambas suposiciones son incorrectas.

Qué Te Aporta Realmente el Número de Ejecuciones, Estadísticamente

Si modelamos el descubrimiento de bugs como un muestreo con reemplazo desde un espacio de inputs donde el bug tiene probabilidad p de aparecer, la probabilidad de no detectar el bug después de n ejecuciones es (1 - p)^n.

Para p = 0,01, 100 ejecuciones te dan un 37% de probabilidad de no detectar el bug. Para p = 0,001, 100 ejecuciones te dan un 90% de probabilidad de no detectarlo. Para obtener un 99% de confianza de detectar un bug del 0,1%, necesitas unas 4.600 ejecuciones.

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

Esta es la parte que incomoda a la gente. Si quieres alta confianza en bugs raros, necesitas decenas de miles de ejecuciones. Nadie quiere esperar tanto en CI.

El Shrinking Cambia la Ecuación de Coste

El valor por defecto de 100 ejecuciones se estableció antes de que el shrinking fuera tan bueno como lo es hoy. Los frameworks modernos de property-based testing no solo encuentran bugs. Encuentran bugs mínimos.

Eso significa que puedes pensar en términos de presupuesto, no solo de cantidad. Si ejecutas 1.000 ejemplos y encuentras un bug en la ejecución 847, el shrinking podría necesitar otras 200 o 300 ejecuciones para minimizar el counterexample. El coste total es 1.100 o más ejecuciones para un solo bug. Pero si ejecutas 10.000 ejemplos y no encuentras nada, has gastado 10.000 ejecuciones para tener tranquilidad.

El truco consiste en separar el descubrimiento de la validación. Ejecuta una suite pequeña y rápida en CI para obtener feedback inmediato. Ejecuta una suite más grande y lenta durante la noche o en las release branches para obtener mayor confianza.

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

Esto no se trata solo de velocidad. Se trata de densidad de información. Un test de 100 ejecuciones que pasa no te dice casi nada. Un test de 5.000 ejecuciones que pasa te dice algo más. Un test de 100 ejecuciones que falla te dice exactamente dónde mirar.

Cómo Dividimos los Property-Based Tests en Niveles

En nuestra experiencia, el mejor enfoque es dejar de tratar todas las properties por igual. Las dividimos en tres niveles.

Las fast properties se ejecutan en cada pull request. Son las mecánicas. Serialización round-trip, idempotencia de deduplicación, invariantes básicas sobre estructuras de datos. Ejecutamos entre 100 y 200 ejemplos. Se completan en menos de un segundo.

Las deep properties se ejecutan en cada merge a main. Apuntan a state machines complejas, pipelines de procesamiento de events y cualquier cosa con explosión combinatoria. Ejecutamos entre 2.000 y 10.000 ejemplos. Tardan minutos, no horas.

Las exploratory properties se ejecutan manualmente antes de los releases. Son aquellas en las que subimos max_examples a 50.000 o más y dejamos que la máquina trabaje mientras revisamos el changelog. De esta manera hemos encontrado race conditions y casos límite de integer overflow que ninguna cantidad de unit testing habría detectado.

Qué Hacer en Lugar de Adivinar

Deja de tratar max_examples como un dial que configuras una vez y olvidas. Trátalo como una configuración que pertenece a la property, no al framework.

Hazte tres preguntas por cada property que escribas.

¿Qué tan costoso es ejecutar este test? Si cada ejemplo tarda 50ms, 10.000 ejecuciones son 8 minutos. Si tarda 5ms, es menos de un minuto.

¿Qué tan grave es el bug si no lo detectamos? Un bug de formato en un mensaje de log no es lo mismo que un bug de corrupción de datos en un pipeline de pagos.

¿Qué tan rara es la condición que lo activa? Si el bug solo aparece en años bisiestos, o cuando dos UUIDs colisionan, o exactamente en INT_MAX, necesitas más ejecuciones o un generator más inteligente.

Los generators más inteligentes casi siempre ganan a más ejecuciones. Si estás probando un parser JSON, no generes strings aleatorias y esperes que sean válidas. Genera objetos válidos y luego mutalos.

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 ejecuciones con un buen generator superan a 10.000 ejecuciones con uno malo. Siempre.

Preguntas Frecuentes Sobre el Dimensionamiento de Property-Based Tests

¿No significan más ejecuciones siempre mejor cobertura?

No exactamente. El property-based testing no tiene una metric de coverage en el sentido tradicional. Más ejecuciones aumentan la probabilidad de encontrar un bug, pero los rendimientos decrecientes aparecen rápidamente. Duplicar de 100 a 200 ejecuciones es significativo. Duplicar de 10.000 a 20.000 rara vez lo es.

¿Y el fuzzing? ¿No es eso simplemente property-based testing con millones de ejecuciones?

El fuzzing es adyacente pero diferente. Los fuzzers típicamente ejecutan millones de inputs sin comprensión semántica del dominio. El property-based testing usa generators estructurados y shrinking. Puedes pensar en PBT como fuzzing inteligente, o en el fuzzing como PBT por fuerza bruta. El cálculo del número de ejecuciones es diferente porque el coste por ejecución y la información por ejecución no son los mismos.

¿Debería configurar max_examples más alto para CI o más bajo?

Más alto para CI, más bajo para el desarrollo local. Tu portátil es para velocidad. Tu CI es para confianza. Usa un perfil de settings o una variable de entorno para alternar entre ellos.

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")

¿Cómo sé si mi generator es lo suficientemente bueno?

Ejecuta tu test con max_examples configurado muy alto, digamos 50.000, y observa el reporte de coverage. Si faltan branches, tu generator no los está ejercitando. Arregla el generator antes de reducir el número de ejecuciones.

Deja de Buscar el Número Perfecto de Ejecuciones y Empieza a Medir

No existe un número universalmente correcto de ejecuciones de tests para property-based testing. Solo existe el número correcto para tu property, tus generators, tu presupuesto de CI y el coste del bug que estás intentando prevenir.

Empieza con 100 si es necesario. Pero súbelo para las properties que protegen rutas críticas, y bájalo para las properties que son simples sanity checks. Mide cuánto tardan tus tests. Perfila tus generators. Y recuerda: un property-based test que pasa 100 veces no es una prueba. Es solo evidencia.

Si quieres profundizar, la documentación de Hypothesis sobre test statistics y targeted property-based testing vale la pena leer. La CLI de hypothesis puede mostrarte exactamente en qué ejemplos están gastando tiempo tus tests. Ese es el primer lugar donde mirar cuando estás decidiendo si subir o bajar el dial.