StackPractices
intermediate Por Mathias Paulenko

Implementar Property-Based Testing

Cómo escribir tests property-based con Hypothesis, fast-check y jqwik que generan miles de entradas y encuentran casos límite.

Temas: testing

Descripción General

Los tests tradicionales basados en ejemplos verifican una entrada a la vez (assert reverse("abc") == "cba"). Los tests property-based describen propiedades universales (reverse(reverse(s)) == s) y el framework genera cientos de entradas aleatorias buscando violaciones. Este enfoque saca a la luz casos límite — strings vacíos, caracteres Unicode combinantes, overflow de enteros, nulos — que los ejemplos elegidos a mano raramente cubren.

Cuándo Usar

El property-based testing da resultado cuando la corrección se define por invariantes y no por salidas concretas. Para la teoría detrás del enfoque y una comparación de frameworks, consulta la guía completa de property-based testing.

  • Funciones puras con propiedades matemáticas claras (ordenación, parsing, codificación, serialización)
  • Rutinas de validación y sanitización de entrada que deben manejar datos arbitrarios
  • Comportamiento de máquinas de estado donde las transiciones deben preservar invariantes
  • Algoritmos que deben ser reversibles (compress/decompress, encrypt/decrypt, encode/decode)
  • Has tenido bugs en producción causados por entradas de casos límite (colecciones vacías, MAX_INT, caracteres especiales)

Cuándo NO Usar

  • El código depende mucho de I/O o tiene efectos secundarios — las propiedades son difíciles de enunciar y verificar
  • Los tests deben comprobar el comportamiento exacto de escenarios de negocio concretos — usa tests basados en ejemplos
  • La propiedad no puede enunciarse formalmente (“se ve bien para un humano”)
  • El tiempo de ejecución importa — los property tests ejecutan cientos de iteraciones y pueden ser lentos

Cómo Funciona

Cada ejecución property-based es un bucle de tres etapas: generar, comprobar, reducir.

Mermaid flowchart LR diagram

El generador saca entradas de una estrategia (st.text() en Hypothesis, fc.string() en fast-check, Arbitrary<String> en jqwik). Cuando la propiedad falla, el shrinker simplifica la entrada paso a paso hasta que ningún caso más pequeño sigue fallando — un array de 100 elementos se reduce a los tres que de verdad importan. El framework también imprime la seed de la ejecución aleatoria, así que cualquier fallo se puede repetir de forma determinista.

Implementación Paso a Paso

Python (Hypothesis)

import json
from hypothesis import given, strategies as st
from hypothesis.stateful import (
    RuleBasedStateMachine, rule, precondition, invariant,
)

# Propiedad básica: revertir dos veces devuelve el original
@given(st.text())
def test_reverse_is_involution(s):
    assert reverse(reverse(s)) == s

# Estrategia restringida
@given(st.integers(min_value=0, max_value=1000))
def test_square_is_non_negative(n):
    assert n * n >= 0

# Estrategia compuesta para objetos de dominio
@st.composite
def users(draw):
    return {
        "name": draw(st.text(min_size=1, max_size=100)),
        "age": draw(st.integers(min_value=0, max_value=150)),
        "email": draw(st.emails()),
    }

@given(users())
def test_user_serialization_roundtrip(user):
    assert json.loads(json.dumps(user)) == user

# Testing con estado: ejercita un stack y comprueba un invariante tras cada comando
class StackMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.items = []

    @rule(x=st.integers())
    def push(self, x):
        self.items.append(x)

    @precondition(lambda self: len(self.items) > 0)
    @rule()
    def pop(self):
        self.items.pop()

    @invariant()
    def size_is_never_negative(self):
        assert len(self.items) >= 0

TestStack = StackMachine.TestCase

JavaScript (fast-check)

import fc from 'fast-check';

// Propiedad: reverse(reverse(s)) === s
fc.assert(
  fc.property(fc.string(), (s) => {
    return reverse(reverse(s)) === s;
  }),
  { numRuns: 1000 }
);

// Propiedad: ordenar conserva la longitud y produce una lista monótona
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    const sorted = arr.slice().sort((a, b) => a - b);
    for (let i = 1; i < sorted.length; i++) {
      if (sorted[i - 1] > sorted[i]) return false;
    }
    return sorted.length === arr.length;
  })
);

// Testing basado en modelo: los comandos implementan check() y run()
class ListModel {
  constructor() { this.items = []; }
  push(x) { this.items.push(x); }
  pop() { return this.items.pop(); }
  get length() { return this.items.length; }
}

class PushCommand {
  constructor(value) { this.value = value; }
  check() { return true; }
  run(model, real) {
    model.push(this.value);
    real.push(this.value);
  }
}

class PopCommand {
  check(model) { return model.length > 0; }
  run(model, real) {
    if (real.pop() !== model.pop()) {
      throw new Error('pop mismatch between model and implementation');
    }
  }
}

fc.assert(
  fc.property(
    fc.commands([
      fc.integer().map((n) => new PushCommand(n)),
      fc.constant(new PopCommand()),
    ]),
    (cmds) => {
      fc.modelRun(() => ({ model: new ListModel(), real: new MyList() }), cmds);
    }
  )
);

// Shrinking: fast-check reduce la entrada que falla al caso más pequeño
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    return sum(arr) >= 0; // falla cuando los negativos generados suman menos que cero
  })
);

Java (jqwik)

import net.jqwik.api.*;
import net.jqwik.api.stateful.*;
import java.util.List;
import java.util.Stack;

class StringProperties {

    @Property
    boolean reverseOfReverseIsOriginal(@ForAll String s) {
        return reverse(reverse(s)).equals(s);
    }

    @Property
    boolean concatenationLengthIsSum(
        @ForAll @StringLength(min = 0, max = 100) String a,
        @ForAll @StringLength(min = 0, max = 100) String b
    ) {
        return (a + b).length() == a.length() + b.length();
    }

    @Property
    boolean sortedListIsOrdered(@ForAll List<@IntRange(min = -1000, max = 1000) Integer> numbers) {
        List<Integer> sorted = numbers.stream().sorted().toList();
        for (int i = 1; i < sorted.size(); i++) {
            if (sorted.get(i - 1) > sorted.get(i)) return false;
        }
        return true;
    }

    // Arbitraries personalizados (generadores)
    @Provide
    Arbitrary<Email> validEmails() {
        return Combinators.combine(
            Arbitraries.strings().alpha().ofLength(5),
            Arbitraries.of("gmail.com", "yahoo.com", "example.com")
        ).as((local, domain) -> new Email(local + "@" + domain));
    }

    @Property
    boolean emailParsingRoundTrip(@ForAll("validEmails") Email email) {
        return Email.parse(email.toString()).equals(email);
    }
}

// Testing con estado usando ActionChain
class StackProperties {

    @Property
    void stackNeverCorrupts(@ForAll("stackActions") ActionChain<Stack<Integer>> chain) {
        chain.run();
    }

    @Provide
    ActionChainArbitrary<Stack<Integer>> stackActions() {
        return ActionChain.startWith(Stack::new)
            .withAction(pushActions())
            .withAction(new PopAction());
    }

    Arbitrary<Action<Stack<Integer>>> pushActions() {
        return Arbitraries.integers().between(-1000, 1000)
            .map(PushAction::new);
    }

    static class PushAction implements Action<Stack<Integer>> {
        private final int value;
        PushAction(int value) { this.value = value; }
        @Override
        public Stack<Integer> run(Stack<Integer> stack) {
            stack.push(value);
            return stack;
        }
    }

    static class PopAction implements Action<Stack<Integer>> {
        @Override
        public boolean precondition(Stack<Integer> stack) {
            return !stack.isEmpty();
        }
        @Override
        public Stack<Integer> run(Stack<Integer> stack) {
            stack.pop();
            return stack;
        }
    }
}

Lo que funciona

  • Empieza por las propiedades, no por los generadores. Lo difícil del property-based testing es encontrar la propiedad correcta (encode(decode(x)) == x), no escribir el generador.
  • Usa el shrinking a fondo. El valor del enfoque está en el caso de fallo mínimo, así que asegúrate de que el shrinking de tu framework esté activado y revisa su salida en lugar de depurar la entrada aleatoria cruda.
  • Combínalo con tests basados en ejemplos. Las propiedades verifican invariantes; los ejemplos verifican escenarios de negocio concretos. Necesitas ambos.
  • Mantén las propiedades puras. Una propiedad que escribe en una base de datos o lee la hora actual no es reproducible y no se puede reducir bien.
  • Usa una seed determinista en CI. Los property tests son aleatorios por naturaleza; una seed hace que los fallos sean reproducibles entre ejecuciones.
  • Restringe los generadores a datos realistas. Las entradas bien formadas llegan antes a los fallos significativos — consulta la receta de generación de datos de test para estrategias que codifican reglas de dominio.

Errores Comunes

  • Testear la implementación, no la especificación. Escribir property: sort(arr) == mySortFunction(arr) es tautológico y no encuentra bugs. Una suite que no detecta fallos inyectados es decorativa — el mutation testing mide exactamente eso.
  • Propiedades demasiado débiles. length(f(x)) >= 0 siempre es verdadero y no aporta nada. Las propiedades deben ser lo bastante fuertes para atrapar bugs reales.
  • Ignorar la salida del shrinking. Un array de 100 elementos que falla es difícil de depurar; el array reducido de tres elementos es lo que deberías analizar.
  • Generadores lentos o que no terminan. Generar estructuras recursivas sin límites de profundidad puede colgar la ejecución de tests.
  • Propiedades flaky por estado global. Una propiedad que muta un contador a nivel de módulo falla de forma impredecible según el orden de ejecución.

Solución de Problemas

  • La misma propiedad pasa y falla aleatoriamente: la función bajo test toca tiempo, aleatoriedad o estado compartido. Aísla el estado y fija la seed reportada antes de depurar.
  • La generación se cuelga o va muy lenta: las estrategias recursivas o sin límite producen valores enormes. Limita la profundidad (st.recursive(..., max_leaves=10)), acota el tamaño de las colecciones y mide el tiempo de generación por separado del tiempo de aserción.
  • Un fallo desaparece al reejecutar: la seed cambió entre ejecuciones. Copia la seed que Hypothesis imprime con @reproduce_failure, o pasa { seed } a fc.assert, y después escribe un test basado en ejemplos para la entrada reducida.
  • El shrinking tarda más que la ejecución del test: las estrategias muy anidadas encarecen la reducción. Simplifica la estrategia o acota maxShrinks — un caso mínimo algo mayor sigue siendo útil.
  • La suite es demasiado lenta para CI: baja max_examples, numRuns o tries en el perfil de CI y deja las ejecuciones completas para un job nocturno.

Lectura Adicional

Preguntas frecuentes

¿Qué es property-based testing?

En lugar de escribir entradas de ejemplo, defines propiedades que deberían cumplirse para cualquier entrada válida de una función. El framework genera cientos de entradas aleatorias e intenta encontrar un contraejemplo — una sola entrada que viola la propiedad.

¿Qué es el shrinking en property-based testing?

Cuando se encuentra un contraejemplo, el framework reduce la entrada al valor más pequeño que sigue fallando. Un string generado de 500 caracteres puede reducirse a "" o "\ud800" (un surrogate aislado), lo que hace la depuración mucho más rápida que inspeccionar el caso aleatorio crudo.

¿Cómo escribo propiedades para sistemas con estado?

Modela el sistema como una máquina de estados y genera secuencias de comandos (ej., push(x), pop(), peek()). Define un invariante que se cumpla después de cada comando — para un stack, pop() devuelve el último valor insertado. Usa el soporte de testing con estado de cada framework: RuleBasedStateMachine en Hypothesis, fc.commands con objetos Command en fast-check, o ActionChain en jqwik. Ejecuta suficientes iteraciones para cubrir intercalaciones (más de 500 ejecuciones).

¿Cómo escribo propiedades para ciclos de serialización?

La propiedad clásica: deserialize(serialize(x)) == x para todo x válido. Genera objetos del tipo destino, serialízalos, deserializa el resultado y verifica la igualdad. Para JSON, genera objetos con st.recursive que contengan strings, ints, floats, listas y dicts. Gestiona la pérdida de precisión de forma explícita: los floats pueden no sobrevivir exactos a JSON, así que compara con tolerancia en lugar de igualdad estricta.

¿Puede el property-based testing funcionar con funciones con efectos secundarios?

Aísla los efectos secundarios con inyección de dependencias o mocks. Genera las entradas, ejecuta la función con una base de datos o API mockeada y verifica propiedades sobre la secuencia de llamadas realizadas (ej., "cada insert va seguido de un commit" o "ninguna lectura ocurre después de una escritura en la misma clave"). Para funciones con estado acumulado, usa el soporte de testing con estado descrito en la pregunta sobre sistemas con estado.

¿Cómo escribo propiedades para funciones asíncronas?

Envuelve las funciones async en un adaptador síncrono con asyncio.run() o pytest-asyncio. En fast-check, usa fc.asyncProperty con arbitraries asíncronos. Para propiedades concurrentes, genera listas de operaciones async y verifica los invariantes una vez que todas se resuelven. La librería anyio permite testear la misma propiedad contra los backends asyncio y trio.

¿Qué hago cuando una propiedad falla?

Los frameworks imprimen el contraejemplo reducido y la seed al fallar. En Hypothesis, añade @reproduce_failure con el blob impreso, o @example para fijar el caso. En fast-check, pasa la seed reportada a fc.assert. En jqwik, configura seed en @Property. Después escribe un test unitario con la entrada exacta que falla para depurar paso a paso en tu IDE. Si la entrada reducida sigue siendo compleja, restringe más el espacio de búsqueda con assume() o fc.pre().

¿Cómo ejecuto tests property-based en CI sin ralentizar el pipeline?

Baja max_examples (Hypothesis), numRuns (fast-check) o tries (jqwik) en el perfil de CI — entre 50 y 100 iteraciones por PR y más de 1000 en una ejecución nocturna. Aísla la suite detrás de un marcador (pytest -m property_based o npm run test:property) para que no bloquee los pipelines rápidos, y guarda en caché la base de ejemplos de Hypothesis entre ejecuciones para que los fallos conocidos se repitan primero.

¿Puedo generar datos realistas en lugar de entradas puramente aleatorias?

Usa combinadores de estrategias para restringir los datos generados a rangos realistas: patrones string@string.string para emails, límites de negocio para fechas (1900-2100), generadores recursivos para JSON anidado. Librerías como hypothesis-jsonschema generan datos que cumplen un JSON Schema. Extrae las estrategias reutilizables en un módulo compartido (test/strategies.py) para que todas las suites usen las mismas reglas de dominio.

¿Cómo combino property-based testing con fuzzing?

El property-based testing ya es una forma de fuzzing — entradas aleatorias más aserciones. Para fuzzing guiado por cobertura, usa Atheris (Python) o jsfuzz (JavaScript), que siguen la cobertura del código y mutan las entradas para alcanzar nuevas ramas. Combínalos definiendo propiedades que el fuzzer comprueba: el fuzzer maximiza la cobertura mientras las aserciones de la propiedad verifican la corrección. atheris.FuzzedDataProvider alimenta datos estructurados del fuzzer a tu propiedad.