Las pruebas basadas en propiedades comprueban una regla general con muchas entradas generadas automáticamente. En vez de verificar solo sorted([3, 1]) == [1, 3], declaras que ordenar cualquier lista válida conserva sus elementos, produce orden ascendente y es idempotente. Hypothesis explora el dominio y reduce cualquier fallo a un contraejemplo pequeño.
Este enfoque no reemplaza las pruebas convencionales. Añade exploración sistemática a una suite de pruebas automatizadas con pytest, sobre todo en parsers, serialización, cálculos, validación y transformaciones donde unos pocos ejemplos cubren muy poco.
Instalación y primera propiedad
Crea un entorno virtual de Python e instala los paquetes:
python -m pip install hypothesis pytest
Supongamos que la aplicación normaliza nombres:
def normalizar_nombre(nombre: str) -> str:
return " ".join(nombre.split()).casefold()
Normalizar debe ser idempotente: una segunda aplicación no puede cambiar el resultado.
from hypothesis import given, strategies as st
from app.texto import normalizar_nombre
@given(st.text())
def test_normalizacion_es_idempotente(texto: str) -> None:
una_vez = normalizar_nombre(texto)
dos_veces = normalizar_nombre(una_vez)
assert dos_veces == una_vez
Ejecuta pytest -q. @given obtiene valores de st.text(): cadenas vacías, Unicode, espacios poco habituales y combinaciones difíciles de imaginar. La documentación oficial de Hypothesis explica la configuración y el comportamiento vigentes.
Las strategies describen entradas válidas
Una strategy no es solo un generador aleatorio. Describe cómo crear y simplificar valores. Hay strategies para enteros, decimales, fechas, texto, binarios, listas, conjuntos, diccionarios y sus combinaciones.
from hypothesis import given, strategies as st
@given(st.lists(st.integers(), max_size=200))
def test_ordenacion_conserva_contenido(valores: list[int]) -> None:
resultado = sorted(valores)
assert len(resultado) == len(valores)
assert resultado == sorted(resultado)
assert sorted(resultado, reverse=True) == list(reversed(resultado))
Las restricciones deben modelar el dominio real. Para porcentajes entre cero y cien, usa st.integers(min_value=0, max_value=100). No filtres valores solo para conseguir que la prueba pase. Un uso excesivo de .filter() descarta ejemplos y puede activar avisos; expresa los límites en la strategy o construye objetos con st.builds().
Cuando dos campos dependen entre sí, usa @st.composite:
@st.composite
def intervalos(draw):
inicio = draw(st.integers(-10_000, 10_000))
fin = draw(st.integers(min_value=inicio, max_value=inicio + 1_000))
return inicio, fin
@given(intervalos())
def test_longitud_no_es_negativa(intervalo):
inicio, fin = intervalo
assert fin - inicio >= 0
Así se conserva fin >= inicio sin rechazar pares. Para modelos, st.builds(Producto, nombre=..., precio=...) permite reutilizar el constructor y mantener la generación próxima al tipo.
Cómo elegir invariantes valiosos
Una invariante sigue siendo cierta en todo el dominio probado. Las propiedades fuertes describen comportamiento sin copiar la implementación. Algunos patrones son:
- Idempotencia: repetir una operación no cambia el resultado, como al normalizar o eliminar duplicados.
- Ida y vuelta: decodificar un valor codificado recupera el original.
- Conservación: ordenar conserva longitud y elementos; una transferencia conserva el dinero total.
- Límites: el resultado permanece en el intervalo documentado.
- Comparación con un oráculo: una implementación optimizada coincide con un modelo sencillo y evidentemente correcto.
Una ida y vuelta de JSON debe limitarse a valores compatibles con el formato y la igualdad elegida:
import json
from hypothesis import given, strategies as st
json_escalar = st.none() | st.booleans() | st.integers() | st.text()
json_valor = st.recursive(
json_escalar,
lambda hijos: st.lists(hijos, max_size=10)
| st.dictionaries(st.text(), hijos, max_size=10),
max_leaves=30,
)
@given(json_valor)
def test_json_ida_y_vuelta(valor) -> None:
assert json.loads(json.dumps(valor)) == valor
Excluir floats es una decisión explícita: valores no finitos y detalles de representación necesitan otra propiedad. Las anotaciones del artículo sobre type hints en Python aclaran contratos, pero no los aplican durante la ejecución.
Shrinking: encontrar el defecto real
Tras detectar un fallo, Hypothesis busca valores más simples que continúen fallando. Este proceso se denomina shrinking. Una lista grande puede convertirse en [0] y una cadena compleja en un carácter. El contraejemplo mínimo suele revelar la condición ausente mejor que la primera entrada encontrada.
No supongas que el valor mostrado se generó primero ni dependas del orden de generación. Evita capturar excepciones generales dentro de la propiedad, porque podrías ocultar el fallo. Si una excepción forma parte del contrato, exprésalo con pytest.raises.
El shrinking también explica por qué una strategy personalizada debe usar primitivas de Hypothesis y no random. Hypothesis entiende y simplifica sus propias elecciones; un dato aleatorio opaco no participa bien en ese proceso.
Reproducibilidad y regresiones
Hypothesis guarda ejemplos interesantes y fallidos en .hypothesis/, y les da prioridad en ejecuciones posteriores. Esto ofrece reproducibilidad práctica sin renunciar a explorar datos nuevos. Mantener una semilla fija suele reducir esa exploración y no debería ser la opción predeterminada.
Si el fallo merece documentación visible, añade un ejemplo explícito:
from hypothesis import example, given, strategies as st
@given(st.text())
@example("\u00a0") # regresión conocida: espacio de no separación
def test_nombre_no_termina_en_espacio(texto: str) -> None:
assert not normalizar_nombre(texto).endswith(" ")
El informe puede ofrecer @reproduce_failure(...), que reproduce un ejemplo codificado con una versión compatible. Resulta útil para diagnosticar; después de corregir, un @example legible suele documentar mejor la regresión. Ejecuta todo en el pipeline descrito en la guía de CI para Python con GitHub Actions.
Estrategias compuestas y coste de las pruebas
No todos los dominios caben en una combinación directa de lists() y dictionaries(). Cuando un valor generado depende de otro, usa @st.composite para construir un objeto coherente. Una estrategia de intervalos, por ejemplo, puede sortear primero el inicio y limitar el final a un valor igual o mayor. Es preferible a generar dos enteros independientes y descartar la mayoría con assume().
@st.composite
def intervalos(draw):
inicio = draw(st.integers(min_value=-1000, max_value=1000))
fin = draw(st.integers(min_value=inicio, max_value=1000))
return inicio, fin
Los filtros y assume() siguen siendo útiles para condiciones ocasionales, pero rechazar demasiados datos reduce la exploración y puede activar un health check. Siempre que sea posible, genera solo valores válidos. Evita también estrategias mucho más amplias que el contrato probado. Crear textos Unicode enormes para un campo limitado a 20 caracteres consume tiempo y no representa la interfaz pública.
El rendimiento forma parte del diseño de la prueba. Empieza con la configuración predeterminada, mide la duración de la suite y cambia max_examples solo por una razón documentada. Puede ser útil separar perfiles de desarrollo y CI, pero desactivar todos los health checks solo oculta una generación ineficiente. Un fallo inestable del deadline puede deberse al ruido de la máquina; antes de eliminarlo, comprueba que el código probado no haya introducido una ruta inesperadamente lenta.
Cuándo usar Hypothesis
Úsalo cuando el dominio sea amplio y existan propiedades fuertes: codecs, parsers, algoritmos, APIs de transformación, máquinas de estados y reglas financieras con generadores bien limitados. Las pruebas stateful generan secuencias de operaciones, pero normalmente conviene comenzar con funciones puras e invariantes pequeñas.
Prefiere ejemplos para un requisito exacto, un mensaje concreto o una integración costosa. Las interfaces de navegador y los servicios externos inestables no suelen ser el primer objetivo para una generación intensiva. Comprobar únicamente que una entrada no provoca una excepción puede ser una propiedad de robustez válida, aunque es más débil que verificar el resultado.
Errores comunes son duplicar la lógica de producción en la aserción, llamar a random, filtrar casi todos los datos, permitir estructuras recursivas ilimitadas y aumentar max_examples para compensar una strategy deficiente. Otro problema es repetir I/O lento por cada ejemplo; separa la lógica pura y cubre la integración con pocos casos representativos.
Lista de comprobación
- Instala
hypothesisypytestcomo dependencias de desarrollo. - Modela el dominio con strategies precisas y tamaños limitados.
- Elige invariantes independientes de detalles de implementación.
- Mantén cada propiedad rápida, aislada y sin estado global.
- Estudia el contraejemplo reducido antes de restringir la strategy.
- Conserva regresiones importantes con
@examplelegibles. - Ejecuta las propiedades localmente y en CI sin semilla fija por defecto.
Empieza con una función cuyas entradas varíen mucho y cuya salida sea fácil de verificar. Una invariante fuerte respaldada por una strategy fiel resulta más útil que muchos generadores genéricos. Hypothesis aporta más cuando acompaña ejemplos legibles, no cuando intenta sustituirlos.