Descubre las pruebas basadas en propiedades con la biblioteca Hypothesis de Python. Ve más allá de las pruebas basadas en ejemplos para encontrar casos límite y crear software más robusto y fiable.
Más Allá de las Pruebas Unitarias: Una Inmersión Profunda en las Pruebas Basadas en Propiedades con Hypothesis de Python
En el mundo del desarrollo de software, las pruebas son la base de la calidad. Durante décadas, el paradigma dominante ha sido las pruebas basadas en ejemplos. Elaboramos meticulosamente las entradas, definimos las salidas esperadas y escribimos aserciones para verificar que nuestro código se comporta según lo planeado. Este enfoque, que se encuentra en frameworks como unittest
y pytest
, es potente y esencial. ¿Pero qué pasaría si te dijera que existe un enfoque complementario que puede descubrir errores que nunca pensaste buscar?
Bienvenido al mundo de las pruebas basadas en propiedades, un paradigma que cambia el enfoque de probar ejemplos específicos a verificar propiedades generales de tu código. Y en el ecosistema de Python, el campeón indiscutible de este enfoque es una biblioteca llamada Hypothesis.
Esta guía completa te llevará de ser un principiante absoluto a un practicante seguro de las pruebas basadas en propiedades con Hypothesis. Exploraremos los conceptos centrales, profundizaremos en ejemplos prácticos y aprenderemos a integrar esta poderosa herramienta en tu flujo de trabajo de desarrollo diario para crear software más robusto, fiable y resistente a errores.
¿Qué son las Pruebas Basadas en Propiedades? Un Cambio de Mentalidad
Para entender Hypothesis, primero debemos captar la idea fundamental de las pruebas basadas en propiedades. Comparemos esto con las pruebas tradicionales basadas en ejemplos que todos conocemos.
Pruebas Basadas en Ejemplos: El Camino Familiar
Imagina que has escrito una función de ordenación personalizada, my_sort()
. Con las pruebas basadas en ejemplos, tu proceso de pensamiento sería:
- "Probémosla con una lista simple y ordenada." ->
assert my_sort([1, 2, 3]) == [1, 2, 3]
- "¿Qué pasa con una lista en orden inverso?" ->
assert my_sort([3, 2, 1]) == [1, 2, 3]
- "¿Qué tal una lista vacía?" ->
assert my_sort([]) == []
- "¿Una lista con duplicados?" ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5]
- "¿Y una lista con números negativos?" ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
Esto es efectivo, pero tiene una limitación fundamental: solo estás probando los casos en los que puedes pensar. Tus pruebas son solo tan buenas como tu imaginación. Podrías pasar por alto casos límite que involucren números muy grandes, imprecisiones de punto flotante, caracteres unicode específicos o combinaciones complejas de datos que conduzcan a un comportamiento inesperado.
Pruebas Basadas en Propiedades: Pensando en Invariantes
Las pruebas basadas en propiedades invierten el guion. En lugar de proporcionar ejemplos específicos, defines las propiedades, o invariantes, de tu función —reglas que deberían ser ciertas para cualquier entrada válida. Para nuestra función my_sort()
, estas propiedades podrían ser:
- La salida está ordenada: Para cualquier lista de números, cada elemento en la lista de salida es menor o igual al que le sigue.
- La salida contiene los mismos elementos que la entrada: La lista ordenada es solo una permutación de la lista original; no se añaden ni se pierden elementos.
- La función es idempotente: Ordenar una lista ya ordenada no debería cambiarla. Es decir,
my_sort(my_sort(some_list)) == my_sort(some_list)
.
Con este enfoque, no estás escribiendo los datos de prueba. Estás escribiendo las reglas. Luego, dejas que un framework, como Hypothesis, genere cientos o miles de entradas aleatorias, diversas y a menudo insidiosas para intentar demostrar que tus propiedades son falsas. Si encuentra una entrada que rompe una propiedad, ha encontrado un error.
Presentando Hypothesis: Tu Generador de Datos de Prueba Automatizado
Hypothesis es la biblioteca principal de pruebas basadas en propiedades para Python. Toma las propiedades que defines y hace el arduo trabajo de generar datos de prueba para desafiarlas. No es solo un generador de datos aleatorios; es una herramienta inteligente y potente diseñada para encontrar errores de manera eficiente.
Características Clave de Hypothesis
- Generación Automática de Casos de Prueba: Defines la forma de los datos que necesitas (por ejemplo, "una lista de enteros", "una cadena que solo contiene letras", "una fecha y hora en el futuro"), y Hypothesis genera una amplia variedad de ejemplos que se ajustan a esa forma.
- Reducción Inteligente (Shrinking): Esta es la característica mágica. Cuando Hypothesis encuentra un caso de prueba fallido (por ejemplo, una lista de 50 números complejos que bloquea tu función de ordenación), no solo informa esa lista masiva. Simplifica de forma inteligente y automática la entrada para encontrar el ejemplo más pequeño posible que aún cause el fallo. En lugar de una lista de 50 elementos, podría informar que el fallo ocurre con solo
[inf, nan]
. Esto hace que la depuración sea increíblemente rápida y eficiente. - Integración Fluida: Hypothesis se integra perfectamente con frameworks de pruebas populares como
pytest
yunittest
. Puedes agregar pruebas basadas en propiedades junto con tus pruebas existentes basadas en ejemplos sin cambiar tu flujo de trabajo. - Rica Biblioteca de Estrategias: Viene con una vasta colección de "estrategias" integradas para generar desde enteros y cadenas simples hasta estructuras de datos complejas y anidadas, fechas y horas conscientes de la zona horaria, e incluso arrays de NumPy.
- Pruebas de Estado (Stateful Testing): Para sistemas más complejos, Hypothesis puede probar secuencias de acciones para encontrar errores en transiciones de estado, algo que es notoriamente difícil con las pruebas basadas en ejemplos.
Comenzando: Tu Primera Prueba con Hypothesis
Manos a la obra. La mejor manera de entender Hypothesis es verlo en acción.
Instalación
Primero, necesitarás instalar Hypothesis y tu ejecutor de pruebas preferido (usaremos pytest
). Es tan simple como:
pip install pytest hypothesis
Un Ejemplo Simple: Una Función de Valor Absoluto
Consideremos una función simple que se supone que calcula el valor absoluto de un número. Una implementación ligeramente defectuosa podría verse así:
# en un archivo llamado `my_math.py` def custom_abs(x): """Una implementación personalizada de la función de valor absoluto.""" if x < 0: return -x return x
Ahora, escribamos un archivo de prueba, test_my_math.py
. Primero, el enfoque tradicional de pytest
:
# test_my_math.py (Basado en ejemplos) def test_abs_positive(): assert custom_abs(5) == 5 def test_abs_negative(): assert custom_abs(-5) == 5 def test_abs_zero(): assert custom_abs(0) == 0
Estas pruebas pasan. Nuestra función parece correcta basándose en estos ejemplos. Pero ahora, escribamos una prueba basada en propiedades con Hypothesis. ¿Cuál es una propiedad central de la función de valor absoluto? El resultado nunca debe ser negativo.
# test_my_math.py (Basado en propiedades con Hypothesis) from hypothesis import given from hypothesis import strategies as st from my_math import custom_abs @given(st.integers()) def test_abs_property_is_non_negative(x): """Propiedad: El valor absoluto de cualquier entero es siempre >= 0.""" assert custom_abs(x) >= 0
Desglosemos esto:
from hypothesis import given, strategies as st
: Importamos los componentes necesarios.given
es un decorador que convierte una función de prueba normal en una prueba basada en propiedades.strategies
es el módulo donde encontramos nuestros generadores de datos.@given(st.integers())
: Este es el núcleo de la prueba. El decorador@given
le dice a Hypothesis que ejecute esta función de prueba varias veces. En cada ejecución, generará un valor usando la estrategia proporcionada,st.integers()
, y lo pasará como argumentox
a nuestra función de prueba.assert custom_abs(x) >= 0
: Esta es nuestra propiedad. Afirmamos que para cualquier enterox
que Hypothesis imagine, el resultado de nuestra función debe ser mayor o igual a cero.
Cuando ejecutas esto con pytest
, probablemente pasará para muchos valores. Hypothesis intentará 0, -1, 1, números positivos grandes, números negativos grandes y más. Nuestra función simple maneja todos estos correctamente. Ahora, intentemos una estrategia diferente para ver si podemos encontrar una debilidad.
# Probemos con números de punto flotante @given(st.floats()) def test_abs_floats_property(x): assert custom_abs(x) >= 0
Si ejecutas esto, ¡Hypothesis encontrará rápidamente un caso fallido!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis descubrió que nuestra función, cuando recibe float('nan')
(Not a Number), devuelve nan
. La aserción nan >= 0
es falsa. Acabamos de encontrar un error sutil que probablemente no habríamos pensado probar manualmente. Podríamos arreglar nuestra función para manejar este caso, quizás lanzando un ValueError
o devolviendo un valor específico.
Mejor aún, ¿y si el error fuera con un flotante muy específico? El reductor de Hypothesis habría tomado un número fallido grande y complejo y lo habría reducido a la versión más simple posible que aún activara el error.
El Poder de las Estrategias: Creando Tus Datos de Prueba
Las estrategias son el corazón de Hypothesis. Son recetas para generar datos. La biblioteca incluye una amplia gama de estrategias integradas, y puedes combinarlas y personalizarlas para generar prácticamente cualquier estructura de datos que puedas imaginar.
Estrategias Integradas Comunes
- Numéricas:
st.integers(min_value=0, max_value=1000)
: Genera enteros, opcionalmente dentro de un rango específico.st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
: Genera flotantes, con control detallado sobre valores especiales.st.fractions()
,st.decimals()
- Texto:
st.text(min_size=1, max_size=50)
: Genera cadenas unicode de una longitud determinada.st.text(alphabet='abcdef0123456789')
: Genera cadenas a partir de un conjunto de caracteres específico (por ejemplo, para códigos hexadecimales).st.characters()
: Genera caracteres individuales.
- Colecciones:
st.lists(st.integers(), min_size=1)
: Genera listas donde cada elemento es un entero. ¡Observa cómo pasamos otra estrategia como argumento! Esto se llama composición.st.tuples(st.text(), st.booleans())
: Genera tuplas con una estructura fija.st.sets(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
: Genera diccionarios con tipos de clave y valor especificados.
- Temporales:
st.dates()
,st.times()
,st.datetimes()
,st.timedeltas()
. Estos pueden ser conscientes de la zona horaria.
- Varios:
st.booleans()
: GeneraTrue
oFalse
.st.just('constant_value')
: Siempre genera un solo valor constante. Útil para componer estrategias complejas.st.one_of(st.integers(), st.text())
: Genera un valor de una de las estrategias proporcionadas.st.none()
: Genera soloNone
.
Combinando y Transformando Estrategias
El verdadero poder de Hypothesis proviene de su capacidad para construir estrategias complejas a partir de otras más simples.
Usando .map()
El método .map()
te permite tomar un valor de una estrategia y transformarlo en algo más. Esto es perfecto para crear objetos de tus clases personalizadas.
# Una clase de datos simple from dataclasses import dataclass @dataclass class User: user_id: int username: str # Una estrategia para generar objetos User user_strategy = st.builds( User, user_id=st.integers(min_value=1), username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz') ) @given(user=user_strategy) def test_user_creation(user): assert isinstance(user, User) assert user.user_id > 0 assert user.username.isalpha()
Usando .filter()
y assume()
A veces necesitas rechazar ciertos valores generados. Por ejemplo, podrías necesitar una lista de enteros cuya suma no sea cero. Podrías usar .filter()
:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
Sin embargo, usar .filter()
puede ser ineficiente. Si la condición es frecuentemente falsa, Hypothesis podría pasar mucho tiempo intentando generar un ejemplo válido. Un mejor enfoque suele ser usar assume()
dentro de tu función de prueba:
from hypothesis import assume @given(st.lists(st.integers())) def test_something_with_non_zero_sum_list(numbers): assume(sum(numbers) != 0) # ... tu lógica de prueba aquí ...
assume()
le dice a Hypothesis: "Si esta condición no se cumple, descarta este ejemplo e intenta uno nuevo". Es una forma más directa y a menudo más eficiente de restringir tus datos de prueba.
Usando st.composite()
Para la generación de datos verdaderamente compleja donde un valor generado depende de otro, st.composite()
es la herramienta que necesitas. Te permite escribir una función que toma una función especial draw
como argumento, que puedes usar para extraer valores de otras estrategias paso a paso.
Un ejemplo clásico es generar una lista y un índice válido dentro de esa lista.
@st.composite def list_and_index(draw): # Primero, extrae una lista no vacía my_list = draw(st.lists(st.integers(), min_size=1)) # Luego, extrae un índice que está garantizado que sea válido para esa lista index = draw(st.integers(min_value=0, max_value=len(my_list) - 1)) return (my_list, index) @given(data=list_and_index()) def test_list_access(data): my_list, index = data # Este acceso está garantizado que sea seguro debido a cómo construimos la estrategia element = my_list[index] assert element is not None # Una simple aserción
Hypothesis en Acción: Escenarios del Mundo Real
Apliquemos estos conceptos a problemas más realistas que los desarrolladores de software enfrentan a diario.
Escenario 1: Probando una Función de Serialización de Datos
Imagina una función que serializa un perfil de usuario (un diccionario) a una cadena segura para URL y otra que la deserializa. Una propiedad clave es que el proceso debería ser perfectamente reversible.
import json import base64 def serialize_profile(data: dict) -> str: """Serializa un diccionario a una cadena base64 segura para URL.""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """Deserializa una cadena de vuelta a un diccionario.""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # Ahora para la prueba # Necesitamos una estrategia que genere diccionarios compatibles con JSON json_dictionaries = st.dictionaries( keys=st.text(), values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=10) ) @given(profile=json_dictionaries) def test_serialization_roundtrip(profile): """Propiedad: Deserializar un perfil codificado debe devolver el perfil original.""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
Esta única prueba golpeará nuestras funciones con una enorme variedad de datos: diccionarios vacíos, diccionarios con listas anidadas, diccionarios con caracteres unicode, diccionarios con claves extrañas y más. Es mucho más exhaustivo que escribir algunos ejemplos manuales.
Escenario 2: Probando un Algoritmo de Ordenación
Volvamos a nuestro ejemplo de ordenación. Aquí es cómo probarías las propiedades que definimos anteriormente.
from collections import Counter def my_buggy_sort(numbers): # Introduzcamos un error sutil: descarta duplicados return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # Propiedad 1: La salida está ordenada for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # Propiedad 2: Los elementos son los mismos (esto encontrará el error) assert Counter(numbers) == Counter(sorted_list) # Propiedad 3: La función es idempotente assert my_buggy_sort(sorted_list) == sorted_list
Cuando ejecutas esta prueba, Hypothesis encontrará rápidamente un ejemplo fallido para la Propiedad 2, como numbers=[0, 0]
. Nuestra función devuelve [0]
, y Counter([0, 0])
no es igual a Counter([0])
. El reductor se asegurará de que el ejemplo fallido sea lo más simple posible, haciendo que la causa del error sea inmediatamente obvia.
Escenario 3: Pruebas de Estado (Stateful Testing)
Para objetos con estado interno que cambia con el tiempo (como una conexión a base de datos, un carrito de compras o una caché), encontrar errores puede ser increíblemente difícil. Puede ser necesaria una secuencia específica de operaciones para activar una falla. Hypothesis proporciona `RuleBasedStateMachine` precisamente para este propósito.
Imagina una API simple para un almacén de clave-valor en memoria:
class SimpleKeyValueStore: def __init__(self): self._data = {} def set(self, key, value): self._data[key] = value def get(self, key): return self._data.get(key) def delete(self, key): if key in self._data: del self._data[key] def size(self): return len(self._data)
Podemos modelar su comportamiento y probarlo con una máquina de estados:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # Bundle() se usa para pasar datos entre reglas keys = Bundle('keys') @rule(target=keys, key=st.text(), value=st.integers()) def set_key(self, key, value): self.model[key] = value self.sut.set(key, value) return key @rule(key=keys) def delete_key(self, key): del self.model[key] self.sut.delete(key) @rule(key=st.text()) def get_key(self, key): model_val = self.model.get(key) sut_val = self.sut.get(key) assert model_val == sut_val @rule() def check_size(self): assert len(self.model) == self.sut.size() # Para ejecutar la prueba, simplemente hereda de la máquina y de unittest.TestCase # En pytest, simplemente puedes asignar la prueba a la clase de la máquina TestKeyValueStore = KeyValueStoreMachine.TestCase
Hypothesis ejecutará ahora secuencias aleatorias de operaciones `set_key`, `delete_key`, `get_key` y `check_size`, intentando implacablemente encontrar una secuencia que cause que una de las aserciones falle. Verificará si obtener una clave eliminada se comporta correctamente, si el tamaño es consistente después de múltiples configuraciones y eliminaciones, y muchos otros escenarios que podrías no pensar en probar manualmente.
Mejores Prácticas y Consejos Avanzados
- La Base de Datos de Ejemplos: Hypothesis es inteligente. Cuando encuentra un error, guarda el ejemplo fallido en un directorio local (
.hypothesis/
). La próxima vez que ejecutes tus pruebas, reproducirá ese ejemplo fallido primero, dándote una retroalimentación inmediata de que el error aún está presente. Una vez que lo arreglas, el ejemplo ya no se reproduce. - Controlando la Ejecución de Pruebas con
@settings
: Puedes controlar muchos aspectos de la ejecución de la prueba usando el decorador@settings
. Puedes aumentar el número de ejemplos, establecer un plazo para cuánto tiempo puede ejecutarse un solo ejemplo (para capturar bucles infinitos) y desactivar ciertas verificaciones de salud.@settings(max_examples=500, deadline=1000) # Ejecuta 500 ejemplos, plazo de 1 segundo @given(...) ...
- Reproduciendo Fallos: Cada ejecución de Hypothesis imprime un valor de semilla (por ejemplo,
@reproduce_failure('version', 'seed')
). Si un servidor de CI encuentra un error que no puedes reproducir localmente, puedes usar este decorador con la semilla proporcionada para forzar a Hypothesis a ejecutar la misma secuencia exacta de ejemplos. - Integración con CI/CD: Hypothesis es un ajuste perfecto para cualquier pipeline de integración continua. Su capacidad para encontrar errores oscuros antes de que lleguen a producción lo convierte en una red de seguridad invaluable.
El Cambio de Mentalidad: Pensar en Propiedades
Adoptar Hypothesis es más que solo aprender una nueva biblioteca; es abrazar una nueva forma de pensar sobre la corrección de tu código. En lugar de preguntar, "¿Qué entradas debo probar?", empiezas a preguntar, "¿Cuáles son las verdades universales sobre este código?"
Aquí hay algunas preguntas para guiarte al intentar identificar propiedades:
- ¿Existe una operación inversa? (por ejemplo, serializar/deserializar, cifrar/descifrar, comprimir/descomprimir). La propiedad es que realizar la operación y su inversa debería producir la entrada original.
- ¿Es la operación idempotente? (por ejemplo,
abs(abs(x)) == abs(x)
). Aplicar la función más de una vez debería producir el mismo resultado que aplicarla una vez. - ¿Existe una forma diferente y más simple de calcular el mismo resultado? Puedes probar que tu función compleja y optimizada produce la misma salida que una versión simple y obviamente correcta (por ejemplo, probar tu elegante ordenación contra el
sorted()
incorporado de Python). - ¿Qué siempre debería ser cierto acerca de la salida? (por ejemplo, la salida de una función `find_prime_factors` solo debería contener números primos, y su producto debería ser igual a la entrada).
- ¿Cómo cambia el estado? (Para pruebas de estado) ¿Qué invariantes deben mantenerse después de cualquier operación válida? (por ejemplo, El número de artículos en un carrito de compras nunca puede ser negativo).
Conclusión: Un Nuevo Nivel de Confianza
Las pruebas basadas en propiedades con Hypothesis no reemplazan las pruebas basadas en ejemplos. Todavía necesitas pruebas específicas escritas a mano para la lógica de negocio crítica y los requisitos bien entendidos (por ejemplo, "Un usuario del país X debe ver el precio Y").
Lo que Hypothesis proporciona es una forma potente y automatizada de explorar el comportamiento de tu código y protegerte contra casos límite imprevistos. Actúa como un compañero incansable, generando miles de pruebas que son más diversas y engañosas de lo que cualquier humano podría escribir de manera realista. Al definir las propiedades fundamentales de tu código, creas una especificación robusta que Hypothesis puede probar, dándote un nuevo nivel de confianza en tu software.
La próxima vez que escribas una función, tómate un momento para pensar más allá de los ejemplos. Pregúntate, "¿Cuáles son las reglas? ¿Qué debe ser siempre cierto?" Luego, deja que Hypothesis haga el arduo trabajo de intentar romperlas. Te sorprenderá lo que encuentra, y tu código será mejor por ello.