Pruebas Basadas en Propiedades con Python e Hypothesis: Más Allá de los Ejemplos Fijos
Como desarrolladores, las pruebas unitarias son nuestro pan de cada día. Escribimos pruebas para funciones específicas con entradas conocidas y esperamos salidas predecibles. Pero, ¿qué pasa con las entradas que no se nos ocurrieron? ¿El espacio en blanco, los números negativos, los caracteres Unicode extraños o simplemente secuencias de datos que nunca imaginamos? Aquí es donde las pruebas tradicionales, basadas en ejemplos, a menudo se quedan cortas y donde las pruebas basadas en propiedades (Property-Based Testing) brillan.
Contexto del Problema
Imagina que tienes una función que comprime una cadena de texto. Podrías escribir una prueba como esta:
def test_compress_simple_string():
assert compress("AAABBC") == "3A2B1C"
def test_compress_empty_string():
assert compress("") == ""
Esto está bien, pero solo cubre dos casos. ¿Qué pasa con "aAaA", emojis "🤔🤔🤔", o una cadena de 10,000 caracteres 'A'? Probar todos estos casos manualmente es tedioso e inviable. El problema fundamental es que estamos verificando ejemplos de comportamiento correcto, no el comportamiento correcto en sí mismo. Las pruebas basadas en propiedades invierten este enfoque: definimos una "propiedad" que debe ser cierta para cualquier entrada válida y dejamos que una herramienta genere cientos de ejemplos para intentar romperla.
Conceptos Clave
Antes de sumergirnos en el código, aclaremos tres conceptos fundamentales que hacen que esta técnica funcione.
- Propiedad: Una característica o invariante de tu código que siempre debe cumplirse. Por ejemplo, para una función
sort(my_list), una propiedad es que "la lista resultante siempre está ordenada". Otra es que "la lista resultante tiene la misma cantidad de elementos que la original". No nos importa el resultado exacto para una lista concreta, sino que estas reglas se cumplan para cualquier lista. - Generadores de Datos (Estrategias): Son los responsables de crear los datos de entrada para tus pruebas. En lugar de escribir
"AAABBC"a mano, le pides a un generador que te dé "una cadena de texto", "un entero entre 0 y 100" o "una lista de flotantes". Estos generadores son inteligentes y no solo producen valores simples, sino que buscan activamente casos extremos (vacíos, ceros, valores muy grandes, etc.). - Shrinking (Reducción): Esta es la magia de las librerías de PBT. Cuando se encuentra una entrada que rompe una propiedad (un contraejemplo), la herramienta no se limita a mostrártela. Automáticamente intenta reducirla al caso más simple posible que todavía causa el error. Si tu prueba falla con la cadena
"Hola\nMundo", el proceso de shrinking podría descubrir que el verdadero problema es el carácter de nueva línea y presentarte"\n"como el fallo mínimo, facilitando enormemente la depuración.
En Python, la librería de referencia para esto es Hypothesis, que se integra a la perfección con frameworks de testing como Pytest.
Implementación Paso a Paso
Vamos a implementar nuestra primera prueba basada en propiedades. Usaremos un ejemplo clásico: la codificación Run-Length (RLE), un algoritmo simple de compresión.
Paso 1: Instalación
Necesitamos pytest para ejecutar las pruebas e hypothesis para escribirlas. Instálalos en tu entorno virtual:
pip install pytest hypothesis
Paso 2: La Función a Probar
Crearemos un archivo llamado rle.py con dos funciones: una que codifica y otra que decodifica. La idea es que si codificamos algo y luego lo decodificamos, deberíamos obtener el original. Esta es una propiedad perfecta, conocida como "round-trip".
# rle.py
import re
def encode_rle(text: str) -> str:
if not text:
return ""
result = []
count = 1
for i in range(1, len(text)):
if text[i] == text[i-1]:
count += 1
else:
result.append(f"{count}{text[i-1]}")
count = 1
result.append(f"{count}{text[-1]}")
return "".join(result)
def decode_rle(encoded_text: str) -> str:
if not encoded_text:
return ""
# Usamos una expresión regular para encontrar pares de (número, carácter)
matches = re.findall(r'(\d+)(\D)', encoded_text)
return "".join([char * int(count) for count, char in matches])
Nota: Esta implementación de RLE es simple y tiene fallos intencionados que Hypothesis nos ayudará a encontrar. Por ejemplo, no maneja números en el texto de entrada.
Paso 3: Escribir la Prueba Basada en Propiedades
Ahora, crea un archivo de prueba, por ejemplo, test_rle.py.
# test_rle.py
from hypothesis import given
from hypothesis import strategies as st
from rle import encode_rle, decode_rle
# El decorador @given le dice a Hypothesis que ejecute esta prueba múltiples veces
# con diferentes argumentos generados por las "estrategias" proporcionadas.
@given(text=st.text())
def test_rle_roundtrip_property(text: str):
"""Verifica que decodificar(codificar(texto)) devuelve el texto original."""
# La propiedad que debe cumplirse
assert decode_rle(encode_rle(text)) == text
Analicemos esto:
1. Importamos given de hypothesis, que es el decorador que activa la magia.
2. Importamos strategies as st, que es el módulo que contiene todos los generadores de datos.
3. @given(text=st.text()): Le decimos a Hypothesis: "Para el argumento `text` de la función de prueba, genera valores usando la estrategia para texto (st.text())".
4. El cuerpo de la prueba es la afirmación de nuestra propiedad: decode(encode(x)) == x.
Paso 4: Ejecución y Análisis del Fallo
Ejecuta las pruebas con Pytest desde tu terminal:
pytest
¡La prueba fallará! Y aquí es donde vemos el poder de Hypothesis. La salida será algo similar a esto:
Falsifying example: test_rle_roundtrip_property(
text='0',
)
UnboundLocalError: local variable 'char' referenced before assignment
Hypothesis no solo encontró un fallo, sino que lo redujo (shrunk) al caso más simple: la cadena '0'. Nuestra función decode_rle falla porque la expresión regular r'(\d+)(\D)' espera un dígito seguido de un no-dígito. Al codificar '0', obtenemos '10', y el decodificador no sabe cómo manejarlo. ¡Hemos encontrado un caso extremo importante sin siquiera pensar en él!
Mini Proyecto / Aplicación Sencilla
Vamos a aplicar esto a un escenario más realista: validar y formatear un perfil de usuario. Supongamos que tenemos una función que toma un diccionario de usuario y devuelve una cadena de resumen.
La Función (con errores sutiles)
# user_profile.py
def format_user_summary(profile: dict) -> str:
"""Formatea un resumen de usuario a partir de un diccionario de perfil."""
username = profile["username"]
age = profile["age"]
# Limpiar y capitalizar el nombre de usuario
clean_username = username.strip().capitalize()
if age < 18:
raise ValueError("El usuario debe ser mayor de edad.")
return f"Usuario {clean_username} ({age} años)"
Esta función parece robusta, pero tiene problemas. ¿Qué pasa si `username` es solo espacios en blanco? .strip() lo convertirá en una cadena vacía, y .capitalize() en una cadena vacía lanzará un IndexError.
Creando una Estrategia Compuesta
Para probar esta función, necesitamos generar diccionarios que coincidan con la estructura de `profile`. Hypothesis nos permite componer estrategias.
# test_user_profile.py
from hypothesis import given, settings
from hypothesis import strategies as st
import pytest
from user_profile import format_user_summary
# Definimos una estrategia para generar perfiles de usuario válidos
user_profile_strategy = st.fixed_dictionaries({
"username": st.text(alphabet=st.characters(min_codepoint=97, max_codepoint=122), min_size=1),
"age": st.integers(min_value=18, max_value=120)
})
@given(profile=user_profile_strategy)
def test_summary_never_fails_with_valid_data(profile):
"""Propiedad: La función nunca debe fallar con datos generados como válidos."""
try:
format_user_summary(profile)
except Exception as e:
pytest.fail(f"La función falló inesperadamente con datos válidos: {profile}. Error: {e}")
# Ahora, una estrategia que SÍ incluye casos problemáticos
problematic_username_strategy = st.text()
@given(username=problematic_username_strategy, age=st.integers(min_value=18))
def test_summary_handles_any_username(username, age):
"""Propiedad: La función debe manejar cualquier nombre de usuario sin crashear."""
profile = {"username": username, "age": age}
# No nos importa el resultado, solo que no lance una excepción inesperada
format_user_summary(profile)
Al ejecutar `pytest` de nuevo, la segunda prueba, test_summary_handles_any_username, fallará. Hypothesis rápidamente encontrará el problema:
Falsifying example: test_summary_handles_any_username(
username=' ',
age=18
)
IndexError: capitalize() on empty string
¡Exacto! El caso mínimo es un solo espacio. Ahora podemos corregir nuestra función:
# user_profile.py (corregido)
def format_user_summary(profile: dict) -> str:
# ...
clean_username = username.strip()
if not clean_username:
raise ValueError("El nombre de usuario no puede estar vacío.")
capitalized_username = clean_username.capitalize()
# ...
return f"Usuario {capitalized_username} ({age} años)"
Errores Comunes y Depuración
- Pruebas Lentas: Si tus estrategias son muy amplias (ej.
st.lists(st.text())), Hypothesis puede tardar mucho. Usa@settings(deadline=500)(en milisegundos) para poner un límite de tiempo por ejemplo. También puedes acotar tus estrategias (min_size,max_size) para que sean más realistas. - Falsos Positivos (Propiedades Incorrectas): A veces la prueba falla no por un error en tu código, sino porque tu propiedad es demasiado estricta o incorrecta. Revisa siempre que la propiedad que estás testeando sea lógicamente sólida.
- Reproducir un Fallo: Cuando Hypothesis encuentra un fallo, imprime un decorador
@example(...)que puedes copiar y pegar encima de tu función de prueba. Esto la convertirá temporalmente en una prueba basada en un solo ejemplo, permitiéndote usar un depurador (comopdb) para analizar ese caso específico.
# Ejemplo para depurar
from hypothesis import example
@example(username=' ', age=18)
@given(username=problematic_username_strategy, age=st.integers(min_value=18))
def test_summary_handles_any_username(username, age):
# ...
Aprendizaje Futuro / Próximos Pasos
Has arañado la superficie de lo que Hypothesis puede hacer. Aquí tienes algunas ideas para seguir explorando:
- Estrategias Compuestas Avanzadas: Investiga
@st.composite. Es un decorador que te permite construir estrategias muy complejas y personalizadas, donde la generación de un valor puede depender de otro. - Pruebas de Estado (Stateful Testing): Hypothesis puede probar sistemas con estado, como una API o una clase que cambia con el tiempo. El módulo
hypothesis.statefulte permite definir una "máquina de estados" con acciones y postcondiciones, y Hypothesis encontrará secuencias de acciones que rompan tus invariantes. - Integración con Frameworks: Explora las librerías de extensión de Hypothesis, como
hypothesis-djangoohypothesis-jsonschema, que proveen estrategias para generar modelos de Django o datos que cumplen un esquema JSON, ideal para probar APIs web. - Filtrado de Datos: A veces una estrategia genera datos que son sintácticamente válidos pero semánticamente incorrectos para tu prueba. Puedes usar el método
.filter(lambda x: ...)en una estrategia para descartar ejemplos no deseados.
Adoptar las pruebas basadas en propiedades requiere un cambio de mentalidad, pasando de "¿qué ejemplos debo probar?" a "¿qué propiedades debe cumplir mi código?". Este cambio no solo te ayudará a encontrar más errores, sino que te obligará a pensar más profundamente sobre el comportamiento y los contratos de tu código, convirtiéndote en un desarrollador más sólido y eficaz.