Hypothesis en Python: property-based testing, strategies y encontrar edge cases automáticamente

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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP