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.
N° 159