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

FrameworkEnfoque ShrinkingCalidad
HypothesisAlgoritmos de reducción integradosExcelente
QuickCheckShrinking basado en tipoExcelente
fast-checkShrinking custom por generadorMuy Bueno
JSVerifyShrinking básicoBueno

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.