Coverage y property-based testing con Hypothesis

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

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio