Property-Based Testing con Hypothesis
Cómo usar Hypothesis para property-based testing en Python, generando cientos de casos de test automáticamente desde strategies en lugar de escribirlos a mano.
Overview
Hypothesis es una librería de property-based testing para Python. En lugar de escribir casos de test individuales con inputs específicos, defines propiedades que deben cumplirse para todos los inputs válidos. Hypothesis genera cientos de casos de test automáticamente usando strategies — generadores de datos con conocimiento de tipos. También reduce los inputs que fallan al caso mínimo que reproduce el bug.
When to Use
- Testear funciones puras donde puedes enunciar invariantes (e.g., “ordenar devuelve los mismos elementos en orden”)
- Encontrar edge cases en parsers, serializers y transformaciones de datos
- Testear propiedades matemáticas (asociatividad, conmutatividad, idempotencia)
- Verificar propiedades de round-trip (encodear y luego decodear devuelve el original)
- Fuzzear código que procesa input no confiable (JSON parsing, URL parsing)
When NOT to Use
- Testear escenarios conocidos específicos — usa
@pytest.mark.parametrizecon casos explícitos - Testear side effects o interacciones stateful — Hypothesis genera datos random, haciendo tests order-dependent flaky
- Testear flujos de UI o integración — property-based testing brilla en lógica pura
- Cuando el runtime del test importa — Hypothesis corre 100+ casos por test por defecto
Solution
Setup
pip install hypothesis pytest
Property test básico
from hypothesis import given
from hypothesis import strategies as st
def reverse_string(s: str) -> str:
return s[::-1]
@given(st.text())
def test_reverse_twice_returns_original(s):
assert reverse_string(reverse_string(s)) == s
@given(st.text())
def test_reverse_preserves_length(s):
assert len(reverse_string(s)) == len(s)
@given(st.text())
def test_reverse_first_becomes_last(s):
if s:
assert reverse_string(s)[0] == s[-1]
Strategies de enteros
@given(st.integers(min_value=0, max_value=100))
def test_square_is_non_negative(n):
assert n * n >= 0
@given(st.integers())
def test_addition_is_commutative(a, b):
assert a + b == b + a
@given(st.integers(), st.integers())
def test_addition_is_commutative(a, b):
assert a + b == b + a
@given(st.integers(min_value=1))
def test_division_then_multiplication(n):
assert (n * 10) // 10 == n
Strategies de listas
@given(st.lists(st.integers(min_value=1, max_value=100), min_size=1))
def test_max_is_in_list(lst):
assert max(lst) in lst
@given(st.lists(st.integers()))
def test_sort_preserves_length(lst):
assert len(sorted(lst)) == len(lst)
@given(st.lists(st.integers()))
def test_sort_is_idempotent(lst):
assert sorted(sorted(lst)) == sorted(lst)
@given(st.lists(st.integers(), min_size=1))
def test_min_le_max(lst):
assert min(lst) <= max(lst)
Strategies de diccionarios y JSON
@given(st.dictionaries(st.text(min_size=1), st.integers()))
def test_dict_items_roundtrip(d):
assert dict(d.items()) == d
@given(st.from_type(dict))
def test_json_roundtrip(d):
import json
encoded = json.dumps(d)
decoded = json.loads(encoded)
assert decoded == d
Strategies personalizadas con @composite
from hypothesis import strategies as st, composite
@composite
def valid_email(draw):
local = draw(st.text(min_size=1, max_size=20, alphabet=st.characters(whitelist_categories=("Ll", "Nd"))))
domain = draw(st.text(min_size=1, max_size=10, alphabet=st.characters(whitelist_categories=("Ll", "Nd"))))
tld = draw(st.sampled_from(["com", "org", "net", "io"]))
return f"{local}@{domain}.{tld}"
@given(valid_email())
def test_email_validation(email):
assert "@" in email
assert "." in email.split("@")[1]
Usar assume para filtrar inputs
from hypothesis import given, assume
from hypothesis import strategies as st
@given(st.integers())
def test_division(n):
assume(n != 0)
assert 100 / n * n == 100
@given(st.lists(st.integers(), min_size=2))
def test_first_not_equal_to_last(lst):
assume(lst[0] != lst[-1])
assert lst[0] != lst[-1]
Stateful testing con RuleBasedStateMachine
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
from hypothesis import strategies as st
class StackMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.stack = []
@rule(value=st.integers())
def push(self, value):
self.stack.append(value)
@rule()
def pop(self):
if self.stack:
self.stack.pop()
@invariant()
def stack_size_non_negative(self):
assert len(self.stack) >= 0
TestStack = StackMachine.TestCase
Controlar número de ejemplos y shrinking
from hypothesis import settings
@given(st.lists(st.integers()))
@settings(max_examples=500, deadline=1000)
def test_with_more_examples(lst):
assert sorted(lst) == sorted(lst, reverse=True)[::-1]
Usar @example para casos específicos
from hypothesis import given, example
from hypothesis import strategies as st
@given(st.lists(st.integers()))
@example([])
@example([1])
@example([1, 2, 3])
def test_sort_with_specific_cases(lst):
assert sorted(lst) == sorted(sorted(lst))
Variants
Testear con st.builds para dataclasses
from dataclasses import dataclass
from hypothesis import given
from hypothesis import strategies as st
@dataclass
class User:
id: int
name: str
email: str
@given(st.builds(User, id=st.integers(min_value=1), name=st.text(min_size=1), email=st.emails()))
def test_user_serialization(user):
data = {"id": user.id, "name": user.name, "email": user.email}
reconstructed = User(**data)
assert reconstructed == user
Testear pandas DataFrames
from hypothesis.extra.pandas import data_frames, column
from hypothesis import given
@given(data_frames([column("a", st.integers()), column("b", st.text())]))
def test_dataframe_shape(df):
assert len(df.columns) == 2
assert "a" in df.columns
assert "b" in df.columns
Best Practices
-
For a deeper guide, see Implement Property-Based Testing.
-
Empieza con strategies simples (
st.text(),st.integers()) y estrecha conmin_value/max_sizesegún sea necesario -
Usa
assume()para filtrar inputs inválidos en lugar de agregar guardsifen el body del test -
Agrega
@examplepara edge cases conocidos (lista vacía, un elemento, valores boundary) -
Setea
deadlinepara evitar timeouts flaky en máquinas de CI lentas -
Usa
@settings(max_examples=N)para aumentar o disminuir el número de casos generados -
Mantén los property tests puros — sin side effects, sin network calls, sin database writes
-
Usa
st.from_type(MyClass)para auto-generar strategies desde type hints
Common Mistakes
- Testear demasiadas propiedades en un test: cada test
@givendebería verificar una propiedad. Múltiples propiedades en un test hacen los fallos difíciles de diagnosticar. - No usar
assume()para precondiciones: los guardsifhacen que Hypothesis desperdicie ejemplos en inputs inválidos. - Ignorar el output de shrinking: cuando un test falla, Hypothesis muestra el input mínimo que falla. Úsalo para entender el bug — no solo arregles el test.
- Usar Hypothesis para tests de integración: property-based testing es para funciones puras. Los side effects hacen los datos generados impredecibles.
- Olvidar registrar tipos personalizados: register_type_strategy(MyClass, my_strategy)
para auto-resolver tipos personalizados enst. from_type()`.
Troubleshooting
- Flaky tests: isolate shared state, time, and randomness. Make tests independent and deterministic; quarantine persistently flaky tests.
- High coverage but bugs in production: coverage does not guarantee correctness. Add mutation testing, property-based tests, or contract tests.
- Slow test suite: parallelize, mock slow dependencies, and avoid end-to-end tests for logic that can be unit tested.
- Tests pass locally but fail in CI: check environment differences, timezone, locale, and dependency versions. Pin tool versions.
- Debugging a failing integration test: Reset state before each test.
Notas de Producción
- Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
- Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
- Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
- Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.
Puntos Clave
- Aplica property-based testing con hypothesis cuando necesites una solución práctica para tu caso de uso.
- Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
- Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
- Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.
Errores Comunes en Producción
- Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
- Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
- Codificar valores fijos que deberían ser configurables por entorno.
- Olvidar agregar logging y monitoreo en cada paso.
- Desplegar sin plan de rollback ni estrategia de backup probada.
- Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
- No documentar la versión y configuración usadas en producción.
- Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.
Preguntas frecuentes
¿Cómo encuentra Hypothesis los edge cases?
Hypothesis usa un motor de generación basado en strategies con coverage feedback. Trackea qué branches de tu código se ejercitan y genera inputs que exploran nuevos branches. Esto encuentra edge cases como strings vacíos, números negativos y caracteres Unicode que el testing manual missa.
¿Qué es shrinking?
Cuando un test falla, Hypothesis intenta encontrar el input más pequeño que aún falla. Por ejemplo, si [3, -1, 0, 42, 17] falla, podría reducir a [0] o [-1] — el reproducer mínimo.
¿Cómo reproduzco un test de Hypothesis que falla?
Hypothesis imprime una seed y el input simplificado que falla. Agrégalo con @example:
@example(s="\x00")
@given(st.text())
def test_with_reproducer(s):
...
¿Puedo usar Hypothesis con Django o Flask?
Sí, pero solo para testear lógica pura (métodos de modelo, funciones utilitarias). No lo uses para views que hittean la base de datos — usa @pytest.mark.django_db con casos de test explícitos en su lugar.
¿Cómo limito el runtime del test?
Usa @settings(max_examples=50, deadline=500) para reducir el número de casos generados y setear un timeout por ejemplo en milisegundos.
Recursos Relacionados
Pytest Fixtures y Parametrize
Cómo usar pytest fixtures y @pytest.mark.parametrize para escribir tests data-driven con lógica de setup reutilizable en proyectos Python.
RecipeMedir y Exigir Cobertura de Tests con pytest-cov
Mide y exige cobertura con pytest-cov: reportes HTML, branch coverage, exclusiones e integración.
RecipeMockear APIs Externas con responses
Cómo mockear llamadas HTTP en tests de Python usando la librería responses, incluyendo códigos de estado, headers, bodies JSON y simulación de errores.
GuideProperty-Based Testing: Hypothesis, fast-check, QuickCheck
Dominá property-based testing con Hypothesis (Python), fast-check (TypeScript) y principios de QuickCheck. Generá test cases automáticamente, encontrá edge cases y shrinkéá failures.
RecipeGenerar Datos de Test
Cómo generar datos de test realistas y deterministas con Faker, factory-boy y generadores type-aware para suites de test confiables en Python, JavaScript y Java.
RecipeImplementar Property-Based Testing
Cómo escribir tests property-based con Hypothesis, fast-check y jqwik que generan miles de entradas para encontrar casos edge que los tests tradicionales no detectan.