Los tests unitarios tradicionales comprueban los casos que se te ocurren. Hypothesis genera automáticamente cientos o miles de inputs distintos para encontrar los que rompen tu código: edge cases como enteros negativos, cadenas vacías, valores en los límites, NaN, listas de un solo elemento. Cuando encuentra un fallo, lo reduce al ejemplo mínimo que lo reproduce mediante un proceso llamado shrinking.
Instalación
# pip install hypothesis # pip install pytest hypothesis # para usar con pytest
@given: el decorador fundamental
from hypothesis import given
import hypothesis.strategies as st
def invertir(s: str) -> str:
return s[::-1]
@given(st.text())
def test_invertir_dos_veces_da_original(s: str):
assert invertir(invertir(s)) == s
@given(st.text())
def test_longitud_se_conserva(s: str):
assert len(invertir(s)) == len(s)
# Ejecutar con pytest: # pytest test_invertir.py -v # Hypothesis muestra cuántos ejemplos probó y cuáles fallaron
Strategies básicas
import hypothesis.strategies as st
from hypothesis import given
# Enteros
@given(st.integers())
def test_enteros_cualquiera(n: int): ...
@given(st.integers(min_value=0, max_value=100))
def test_enteros_acotados(n: int): ...
# Floats
@given(st.floats(allow_nan=False, allow_infinity=False))
def test_floats(x: float): ...
# Texto
@given(st.text(min_size=1, max_size=50))
def test_texto(s: str): ...
@given(st.text(alphabet=st.characters(whitelist_categories=('L', 'N'))))
def test_texto_alfanumerico(s: str): ...
# Listas
@given(st.lists(st.integers()))
def test_lista_enteros(lst: list): ...
@given(st.lists(st.integers(), min_size=1, max_size=20))
def test_lista_no_vacia(lst: list): ...
# Booleanos y opcionales
@given(st.booleans())
def test_bool(b: bool): ...
@given(st.one_of(st.integers(), st.none()))
def test_opcional(valor): ...
Ejemplo real: encontrar un bug en ordenación
from hypothesis import given
import hypothesis.strategies as st
def mi_sort(lista: list[int]) -> list[int]:
"""Implementación casera de insertion sort (con bug deliberado)."""
resultado = lista[:]
for i in range(1, len(resultado)):
clave = resultado[i]
j = i - 1
while j >= 0 and resultado[j] > clave:
resultado[j + 1] = resultado[j]
j -= 1
resultado[j] = clave # BUG: debería ser resultado[j + 1] = clave
return resultado
@given(st.lists(st.integers()))
def test_sort_correcto(lista: list[int]):
resultado = mi_sort(lista)
# Invariantes que siempre deben cumplirse:
assert sorted(lista) == resultado
assert len(resultado) == len(lista)
assert set(resultado) == set(lista)
# Hypothesis encontrará un contraejemplo y lo reducirá al mínimo,
# por ejemplo: [0, -1]
Shrinking: el ejemplo mínimo
Cuando Hypothesis encuentra un input que falla, intenta reducirlo automáticamente al ejemplo más pequeño que sigue fallando. En lugar de reportar una lista de 50 elementos, te da la lista de 2 que reproduces el bug:
from hypothesis import given
import hypothesis.strategies as st
def dividir(a: int, b: int) -> float:
return a / b # falla cuando b == 0
@given(st.integers(), st.integers())
def test_dividir(a: int, b: int):
resultado = dividir(a, b)
assert isinstance(resultado, float)
# Hypothesis reportará: Falsifying example: test_dividir(a=0, b=0)
# No te dará dos números grandes al azar, sino el mínimo que falla.
st.builds(): strategies para tus clases
from dataclasses import dataclass
from hypothesis import given
import hypothesis.strategies as st
@dataclass
class Producto:
nombre: str
precio: float
stock: int
def __post_init__(self):
if self.precio < 0:
raise ValueError("Precio negativo")
if self.stock < 0:
raise ValueError("Stock negativo")
estrategia_producto = st.builds(
Producto,
nombre=st.text(min_size=1, max_size=50),
precio=st.floats(min_value=0.01, max_value=9999.99, allow_nan=False),
stock=st.integers(min_value=0, max_value=10_000),
)
@given(estrategia_producto)
def test_producto_serializable(producto: Producto):
import json
d = {"nombre": producto.nombre, "precio": producto.precio, "stock": producto.stock}
serializado = json.dumps(d)
recuperado = json.loads(serializado)
assert recuperado["stock"] == producto.stock
@composite: strategies personalizadas con lógica
from hypothesis import given
from hypothesis.strategies import composite
import hypothesis.strategies as st
@composite
def par_ordenado(draw) -> tuple[int, int]:
"""Genera una tupla (a, b) donde a <= b."""
a = draw(st.integers())
b = draw(st.integers(min_value=a))
return a, b
@given(par_ordenado())
def test_rango_valido(par: tuple[int, int]):
inicio, fin = par
assert inicio <= fin
rango = list(range(inicio, fin + 1))
assert len(rango) == fin - inicio + 1
Settings: ajustar el comportamiento
from hypothesis import given, settings, HealthCheck
import hypothesis.strategies as st
@settings(
max_examples=500, # más ejemplos para mayor cobertura
deadline=2000, # timeout por ejemplo en ms
suppress_health_check=[HealthCheck.too_slow],
)
@given(st.lists(st.integers(), max_size=100))
def test_intensivo(lst: list[int]):
resultado = sorted(lst)
assert len(resultado) == len(lst)
if len(resultado) > 1:
assert all(resultado[i] <= resultado[i+1] for i in range(len(resultado)-1))
Base de datos de ejemplos
Hypothesis guarda los ejemplos que han fallado en una base de datos local (.hypothesis/). En la siguiente ejecución, comprueba primero esos ejemplos para detectar regresiones inmediatamente, incluso si cambias el código.
Hypothesis es especialmente valioso en funciones matemáticas (where invariantes son fáciles de expresar), parsers, serialización, estructuras de datos y APIs REST. Combínalo con pytest y no solo encontrarás bugs antes de que lleguen a producción, sino que tendrás documentación ejecutable de las propiedades que tu código debe cumplir.
