El Property-Based Testing (PBT) revoluciona cómo pensamos sobre los casos de prueba. En lugar de crear manualmente ejemplos individuales, PBT define propiedades que siempre deben ser verdaderas y automáticamente genera cientos de casos de prueba para verificar esas propiedades. Este enfoque descubre casos edge que los desarrolladores rara vez anticipan.
¿Qué es Property-Based Testing?
Property-Based Testing se enfoca en especificar invariantes—reglas que deben ser siempre verdaderas independientemente de las entradas—en lugar de verificar pares específicos entrada-salida. Un framework de property-based testing genera entradas aleatorias, ejecuta el código bajo prueba y verifica que las propiedades se cumplan.
Conceptos Core
Propiedad: Una aserción sobre el sistema que debe cumplirse para todas las entradas válidas.
Generador: Una función que produce datos de prueba aleatorios que coinciden con restricciones específicas.
Shrinking: Cuando se encuentra una prueba fallida, el framework automáticamente simplifica la entrada para encontrar el caso fallido mínimo.
Invariante: Una condición que permanece verdadera durante la ejecución del programa o a través de transformaciones.
Propiedades vs. Pruebas Basadas en Ejemplos
Testing Tradicional Basado en Ejemplos
def test_reverse_list():
assert reverse([1, 2, 3]) == [3, 2, 1]
assert reverse([]) == []
assert reverse([42]) == [42]
Limitaciones: Solo prueba tres casos específicos; puede omitir casos edge como listas grandes, duplicados o valores inusuales.
Enfoque Property-Based
from hypothesis import given
import hypothesis.strategies as st
@given(st.lists(st.integers()))
def test_reverse_property(lst):
# Propiedad: Revertir dos veces retorna el original
assert reverse(reverse(lst)) == lst
# Propiedad: La longitud se preserva
assert len(reverse(lst)) == len(lst)
# Propiedad: Primer elemento se convierte en último
if lst:
assert reverse(lst)[0] == lst[-1]
Ventajas: Prueba cientos de listas aleatorias automáticamente; descubre casos edge inesperados.
Frameworks de Property-Based Testing
Hypothesis (Python)
Hypothesis es el framework PBT más maduro para Python, ofreciendo estrategias sofisticadas y excelente shrinking.
from hypothesis import given, strategies as st, assume
@given(st.integers(), st.integers())
def test_addition_commutative(a, b):
# Propiedad: La adición es conmutativa
assert a + b == b + a
@given(st.lists(st.integers(), min_size=1))
def test_max_is_in_list(lst):
# Propiedad: El valor max debe estar en la lista
assert max(lst) in lst
@given(st.text())
def test_encode_decode(s):
# Propiedad: Codificar luego decodificar retorna original
encoded = s.encode('utf-8')
decoded = encoded.decode('utf-8')
assert s == decoded
QuickCheck (Haskell)
El framework original de property-based testing que inspiró a todos los demás.
-- Propiedad: Reverse es su propio inverso
prop_reverseInverse :: [Int] -> Bool
prop_reverseInverse xs = reverse (reverse xs) == xs
-- Propiedad: Lista ordenada tiene todos los elementos originales
prop_sortPreservesElements :: [Int] -> Bool
prop_sortPreservesElements xs =
sort xs `sameElements` xs
where
sameElements a b = sort a == sort b
-- Propiedad: Agregar luego tomar longitud suma longitudes
prop_appendLength :: [Int] -> [Int] -> Bool
prop_appendLength xs ys =
length (xs ++ ys) == length xs + length ys
fast-check (JavaScript/TypeScript)
Property-based testing para ecosistema JavaScript con soporte TypeScript.
import fc from 'fast-check';
// Propiedad: Array map preserva longitud
fc.property(
fc.array(fc.integer()),
fc.func(fc.integer()),
(arr, f) => arr.map(f).length === arr.length
);
// Propiedad: Round-trip serialización JSON
fc.property(
fc.anything(),
(value) => {
const serialized = JSON.stringify(value);
const deserialized = JSON.parse(serialized);
return fc.stringify(value) === fc.stringify(deserialized);
}
);
JSVerify (JavaScript)
Biblioteca JavaScript PBT anterior, más simple pero menos rica en features que fast-check.
const jsc = require('jsverify');
// Propiedad: String split luego join retorna original
const splitJoinProperty = jsc.forall(
jsc.string,
jsc.string,
(str, sep) => {
if (sep === '') return true; // Omitir separador vacío
return str.split(sep).join(sep) === str;
}
);
jsc.assert(splitJoinProperty);
Generadores y Estrategias
Los generadores producen datos de prueba aleatorios restringidos a tipos y rangos específicos.
Generadores Built-in
from hypothesis import strategies as st
# Tipos básicos
st.integers() # Cualquier entero
st.integers(min_value=0, max_value=100) # Rango 0-100
st.floats() # Cualquier float
st.text() # Strings Unicode
st.booleans() # True/False
# Colecciones
st.lists(st.integers()) # Listas de enteros
st.sets(st.text(), min_size=1) # Sets no vacíos de strings
st.dictionaries(st.text(), st.integers()) # Dicts String->Int
st.tuples(st.integers(), st.text()) # Tuplas tamaño fijo
# Opcionales y elecciones
st.none() # Siempre None
st.one_of(st.integers(), st.text()) # O int o string
Generadores Personalizados
from hypothesis import strategies as st
from hypothesis.strategies import composite
# Generar direcciones email válidas
@composite
def email_strategy(draw):
username = draw(st.text(
alphabet=st.characters(whitelist_categories=('Ll', 'Nd')),
min_size=1,
max_size=20
))
domain = draw(st.text(
alphabet=st.characters(whitelist_categories=('Ll',)),
min_size=1,
max_size=15
))
tld = draw(st.sampled_from(['com', 'org', 'net', 'edu']))
return f"{username}@{domain}.{tld}"
@given(email_strategy())
def test_email_validation(email):
assert '@' in email
assert '.' in email.split('@')[1]
Composición de Generadores
# Generar carrito de compras con restricciones realistas
@composite
def shopping_cart_strategy(draw):
num_items = draw(st.integers(min_value=0, max_value=50))
items = draw(st.lists(
st.tuples(
st.text(min_size=1), # Nombre producto
st.integers(min_value=1, max_value=10), # Cantidad
st.floats(min_value=0.01, max_value=1000.00) # Precio
),
min_size=num_items,
max_size=num_items
))
return {'items': items}
Shrinking: Casos Fallidos Mínimos
Cuando una propiedad falla, el shrinking automáticamente reduce la entrada al caso más pequeño que aún falla.
Ejemplo: Shrinking en Acción
@given(st.lists(st.integers()))
def test_no_duplicates(lst):
# Esta propiedad es falsa - las listas PUEDEN tener duplicados
assert len(lst) == len(set(lst))
Falla inicial: [0, -1, 3, 0, -5, 2]
Después de shrinking: [0, 0]
El framework automáticamente reduce el caso fallido de una lista de 6 elementos al duplicado mínimo de 2 elementos.
Estrategias de Shrinking
Framework | Enfoque Shrinking | Calidad |
---|---|---|
Hypothesis | Algoritmos de reducción integrados | Excelente |
QuickCheck | Shrinking basado en tipo | Excelente |
fast-check | Shrinking custom por generador | Muy Bueno |
JSVerify | Shrinking básico | Bueno |
Patrones de Propiedades Comunes
Funciones Inversas
Funciones que se deshacen entre sí deben hacer round-trip perfectamente.
@given(st.text())
def test_base64_roundtrip(s):
import base64
encoded = base64.b64encode(s.encode('utf-8'))
decoded = base64.b64decode(encoded).decode('utf-8')
assert s == decoded
Idempotencia
Aplicar una operación múltiples veces tiene el mismo efecto que aplicarla una vez.
@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
# Ordenar dos veces es igual a ordenar una vez
assert sorted(sorted(lst)) == sorted(lst)
@given(st.sets(st.integers()))
def test_set_idempotent(s):
# Convertir a set dos veces es igual a una vez
assert set(set(s)) == set(s)
Invariantes
Ciertas propiedades permanecen verdaderas a través de transformaciones.
@given(st.lists(st.integers()))
def test_filter_preserves_order(lst):
filtered = [x for x in lst if x > 0]
# Orden de elementos filtrados coincide con original
original_positives = [x for x in lst if x > 0]
assert filtered == original_positives
@given(st.dictionaries(st.text(), st.integers()))
def test_dict_keys_values_match(d):
# Keys y values mantienen correspondencia
assert len(d.keys()) == len(d.values())
for key in d.keys():
assert key in d
Comparación con Oráculo
Comparar implementación contra una referencia más simple (pero más lenta).
def quicksort(lst):
# Implementación rápida pero compleja
if len(lst) <= 1:
return lst
pivot = lst[0]
left = [x for x in lst[1:] if x < pivot]
right = [x for x in lst[1:] if x >= pivot]
return quicksort(left) + [pivot] + quicksort(right)
@given(st.lists(st.integers()))
def test_quicksort_matches_builtin(lst):
# Comparar contra sort built-in de Python
assert quicksort(lst) == sorted(lst)
Relaciones Metamórficas
Relacionar salidas para diferentes entradas sin conocer la salida esperada exacta.
@given(st.lists(st.integers()), st.integers())
def test_search_after_insert(lst, value):
# Si insertamos un valor, buscarlo debe tener éxito
lst_with_value = lst + [value]
assert value in lst_with_value
Property Testing Con Estado
Probar sistemas con estado generando secuencias de operaciones.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
import hypothesis.strategies as st
class BankAccountMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.balance = 0
@rule(amount=st.integers(min_value=1, max_value=1000))
def deposit(self, amount):
self.balance += amount
@rule(amount=st.integers(min_value=1, max_value=1000))
def withdraw(self, amount):
if amount <= self.balance:
self.balance -= amount
@invariant()
def balance_never_negative(self):
assert self.balance >= 0
# Ejecutar pruebas con estado
TestBankAccount = BankAccountMachine.TestCase
Ejemplo Hypothesis Stateful Testing
class QueueMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.queue = []
@rule(value=st.integers())
def enqueue(self, value):
self.queue.append(value)
@rule()
def dequeue(self):
if self.queue:
return self.queue.pop(0)
@invariant()
def queue_fifo_order(self):
# Elementos mantienen orden FIFO
assert self.queue == self.queue # Invariante simplificado
Assumptions y Precondiciones
Usar assume()
para filtrar entradas generadas a escenarios válidos.
from hypothesis import given, assume
import hypothesis.strategies as st
@given(st.integers(), st.integers())
def test_division(a, b):
assume(b != 0) # Omitir casos donde b es cero
result = a / b
assert result * b == a # Dentro de precisión punto flotante
Advertencia: Sobreuso de assume()
puede hacer pruebas ineficientes descartando muchas entradas generadas.
Beneficios del Property-Based Testing
Descubre Casos Edge Inesperados
PBT encuentra bugs que los desarrolladores no anticipan.
Caso de Estudio: Hypothesis descubrió un bug de manejo Unicode en un parser JSON de producción que tenía 95% cobertura de líneas con pruebas basadas en ejemplos.
Sirve como Especificación Ejecutable
Las propiedades documentan comportamiento del sistema más comprehensivamente que ejemplos.
Reduce Mantenimiento de Pruebas
Las propiedades permanecen válidas cuando cambian detalles de implementación.
Ejemplo ROI: 50% reducción en actualizaciones de pruebas durante refactoring comparado con pruebas basadas en ejemplos.
Complementa Pruebas Basadas en Ejemplos
Usar PBT para lógica compleja; usar ejemplos para pruebas de regresión específicas y legibilidad.
Desafíos y Limitaciones
Escribir Buenas Propiedades
Identificar propiedades significativas requiere práctica y comprensión profunda.
Mitigación: Comenzar con propiedades simples (round-trip, idempotencia); agregar invariantes complejas gradualmente.
Performance
Generar y ejecutar cientos de casos de prueba toma más tiempo que pruebas basadas en ejemplos.
Mitigación: Configurar conteo de ejemplos; usar CI para runs comprehensivos, menos ejemplos localmente.
from hypothesis import settings
@settings(max_examples=1000) # Default es 100
@given(st.lists(st.integers()))
def test_with_more_examples(lst):
assert len(lst) >= 0
Sistemas No Determinísticos
Sistemas con dependencias externas o aleatoriedad son más difíciles de probar con propiedades.
Mitigación: Usar testing con estado con dependencias mockeadas; probar en límites de integración.
Mejores Prácticas
Comenzar con Propiedades Simples
Empezar con propiedades universalmente aplicables.
# Simple: Preservación de tipo
@given(st.lists(st.integers()))
def test_map_preserves_length(lst):
assert len(list(map(lambda x: x * 2, lst))) == len(lst)
Combinar Múltiples Propiedades
Probar varias propiedades en una prueba para cobertura comprehensiva.
@given(st.lists(st.integers()))
def test_sorting_properties(lst):
sorted_lst = sorted(lst)
# Propiedad 1: Longitud preservada
assert len(sorted_lst) == len(lst)
# Propiedad 2: Todos los elementos presentes
assert set(sorted_lst) == set(lst)
# Propiedad 3: Ordenado correctamente
for i in range(len(sorted_lst) - 1):
assert sorted_lst[i] <= sorted_lst[i + 1]
Usar Decoradores Example para Regresiones
Fijar casos fallidos específicos como ejemplos mientras se mantienen property tests.
from hypothesis import given, example
import hypothesis.strategies as st
@given(st.lists(st.integers()))
@example([]) # Asegurar que lista vacía siempre se pruebe
@example([1, 2, 3]) # Fijar caso de regresión específico
def test_reverse(lst):
assert reverse(reverse(lst)) == lst
Configurar Timeouts Apropiadamente
from hypothesis import settings
import hypothesis.strategies as st
@settings(deadline=500) # 500ms por caso de prueba
@given(st.lists(st.integers(), max_size=10000))
def test_large_lists(lst):
process(lst)
Aplicaciones del Mundo Real
Biblioteca de Parsing JSON
Biblioteca JSON usa PBT para verificar round-trip de serialización para todos los tipos de datos.
Resultados: Descubrió 3 casos edge en manejo Unicode y precisión punto flotante.
Validación de Algoritmo de Ordenamiento
Sort basado en comparación probado contra sort built-in como oráculo.
Resultados: 100% confianza en corrección a través de 10,000 entradas generadas por ejecución de prueba.
Validación de Request API
Validador API REST probado con payloads generados que coinciden con restricciones de schema.
Resultados: Encontró 5 casos edge en validación de objetos anidados que pruebas manuales omitieron.
Conclusión
Property-Based Testing cambia el foco del testing de ejemplos individuales a verdades universales sobre comportamiento del sistema. Si bien requiere una mentalidad diferente que testing tradicional basado en ejemplos, PBT proporciona descubrimiento superior de casos edge y sirve como documentación viva de invariantes del sistema.
El éxito con PBT viene de comenzar simple, identificar propiedades significativas incrementalmente y combinar pruebas basadas en propiedades con pruebas basadas en ejemplos cuidadosamente elegidas para cobertura de regresión y legibilidad.