El coverage mide qué porcentaje de tu código ejecutan tus tests. La herramienta es coverage.py, y la forma habitual de usarla con pytest es:

coverage run -m pytest
coverage report -m

El flag -m muestra las líneas que no se ejecutaron, que es donde realmente está el valor. Un 80% de cobertura no significa que tus tests sean buenos —significa que el 20% restante no se toca en absoluto. Y aquí viene la trampa en la que cae casi todo el mundo: 100% de coverage tampoco garantiza que tus tests sean correctos.

Fíjate en esto. Puedes tener una función como def add(a, b): return a - b con un test assert add(2, 2) == 0 y obtener 100% de cobertura. La línea se ejecuta. El test pasa. La función está rota. Coverage mide qué se ejecuta, no qué se verifica.

Aquí es donde entra property-based testing. En vez de escribir ejemplos concretos (add(2, 3) == 5), defines propiedades que deben cumplirse para cualquier entrada válida y dejas que una librería intente falsificarlas. Hypothesis es la implementación canónica en Python.

El mecanismo interno es relevante: Hypothesis no genera entradas aleatorias ingenuamente. Mantiene una base de datos de casos que han fallado anteriormente y siempre los reintenta. Cuando encuentra un fallo, ejecuta shrinking: reduce el caso fallido a la forma mínima que sigue reproduciéndolo. Si tu función explota con una lista de 10.000 enteros, Hypothesis te va a presentar la lista más pequeña posible que reproduce el crash. Eso transforma un bug críptico en algo directamente debuggeable.

Los casos donde property-based testing brilla son exactamente los que tienen invariantes matemáticas bien definidas: serialización/deserialización (decode(encode(x)) == x), parsers, funciones de ordenación, o cualquier algoritmo donde puedas expresar “esto siempre debe cumplirse” mejor que “para este input, espero este output”.

# pip install hypothesis pytest

import json
from hypothesis import given, settings, HealthCheck
from hypothesis import strategies as st
from typing import Any


# --- Función 1: invariante de serialización ---
def roundtrip(data: Any) -> Any:
    """Serializa a JSON y deserializa. Debe recuperar el valor original."""
    return json.loads(json.dumps(data))


# --- Función 2: parser de rango con bug intencional ---
def parse_range(s: str) -> list[int]:
    """
    Parsea strings del tipo '1-5' y devuelve [1, 2, 3, 4, 5].
    Asume que start <= end.
    """
    start, end = s.split("-")
    # Bug sutil: no valida que start <= end
    return list(range(int(start), int(end) + 1))


# --- Función 3: propiedad de ordenación ---
def my_sort(lst: list[int]) -> list[int]:
    return sorted(lst)


# ──────────────────────────────────────────
# Tests de propiedades con Hypothesis
# ──────────────────────────────────────────

# st.recursive construye estructuras JSON válidas de forma anidada
json_compatible = st.recursive(
    st.none() | st.booleans() | st.integers() | st.floats(allow_nan=False) | st.text(),
    lambda children: st.lists(children) | st.dictionaries(st.text(), children),
    max_leaves=10,
)


@given(json_compatible)
def test_json_roundtrip(data):
    """
    Para cualquier dato serializable como JSON,
    decode(encode(x)) debe ser igual a x.
    """
    assert roundtrip(data) == data


@given(
    start=st.integers(min_value=0, max_value=100),
    end=st.integers(min_value=0, max_value=100),
)
def test_parse_range_length(start, end):
    """
    Si start <= end, el rango parseado debe tener exactamente (end - start + 1) elementos.
    Hypothesis encontrará el caso mínimo donde esto falla.
    """
    if start > end:
        return  # skip casos inválidos explícitamente

    range_str = f"{start}-{end}"
    result = parse_range(range_str)
    assert len(result) == end - start + 1


@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
    """Ordenar dos veces debe dar el mismo resultado que ordenar una."""
    assert my_sort(my_sort(lst)) == my_sort(lst)


@given(st.lists(st.integers()))
def test_sort_preserves_length(lst):
    """Ordenar no debe cambiar el número de elementos."""
    assert len(my_sort(lst)) == len(lst)


@given(st.lists(st.integers(), min_size=1))
def test_sort_min_is_first(lst):
    """El mínimo del resultado debe ser el primer elemento."""
    result = my_sort(lst)
    assert result[0] == min(lst)


# Ejecutar con: coverage run -m pytest test_properties.py -v
#               coverage report -m

Lo que hace cada decisión

st.recursive en json_compatible es la pieza más interesante. Los estrategias simples como st.integers() generan un tipo plano. st.recursive te permite construir estructuras anidadas: listas de dicts de listas de strings, exactamente lo que JSON acepta en la práctica. max_leaves=10 evita que Hypothesis genere estructuras tan profundas que el test se vuelva lento.

El test test_json_roundtrip expresa una invariante real que cualquier sistema de serialización debe cumplir. Nota que estamos usando allow_nan=False en los floats: json.dumps(float('nan')) lanza un excepción en Python por defecto, así que lo excluimos del dominio válido. Ajustar los estrategias al dominio real del sistema es parte del trabajo.

En test_parse_range_length el return explícito cuando start > end es el patrón correcto para entradas fuera del dominio. Hay una alternativa con st.assume(), pero un return temprano es más claro y no consume el presupuesto de ejemplos de Hypothesis.

Los tres tests sobre my_sort ilustran cómo descomponer las propiedades. Ninguno dice “para [3,1,2] el resultado es [1,2,3]”. Cada uno verifica una característica estructural independiente. La ventaja: si cambias la implementación de my_sort, los tests siguen siendo válidos sin modificación.

Errores que debes conocer

Error: usar st.assume() como filtro agresivo hace que Hypothesis descarte demasiados ejemplos y marca el test como unsatisfied, sin ejecutar suficientes casos válidos.

# ❌ Wrong
@given(st.integers(), st.integers())
def test_division(a, b):
    assume(b != 0)  # si el dominio es muy restrictivo, Hypothesis se rinde
    assume(a > 1000)
    assume(b > 1000)
    assert a / b >= 0

# ✅ Right
@given(
    st.integers(min_value=1001),
    st.integers(min_value=1001),
)
def test_division(a, b):
    assert a / b >= 0

Restringir el dominio directamente en la estrategia es más eficiente que filtrar con assume(); Hypothesis genera solo valores válidos desde el principio.

Error: ignorar el archivo .hypothesis/ en el repositorio hace que pierdas la base de datos de casos que han fallado previamente, quitándole a Hypothesis su capacidad de regresión automática.

# ❌ Wrong  — en .gitignore
.hypothesis/

# ✅ Right  — en .gitignore, solo excluir los temporales
.hypothesis/unicodedata/
# el resto se versiona: .hypothesis/examples/ contiene los casos que fallaron

Hypothesis guarda en .hypothesis/examples/ los inputs que han roto tus tests antes. Sin esos datos, cada pytest empieza de cero y podrías pasar semanas sin reproducir un bug intermitente que ya encontraste una vez.

159