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 para encontrar casos edge que los tests tradicionales no detectan.

Temas: testing

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

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 para encontrar violaciones. Este enfoque descubre casos edge — strings vacíos, caracteres Unicode combining, overflow de enteros, null pointers — que los ejemplos elegidos por humanos raramente cubren.

Cuándo Usar

  • For alternatives, see Property-Based Testing Guide.

  • Funciones puras con propiedades matemáticas claras (sorting, parsing, encoding, serialization)

  • Rutinas de validación y sanitización de entrada que deben manejar datos arbitrarios

  • Comportamiento de state machine donde las transiciones deben preservar invariantes

  • Algoritmos que deben ser reversibles (compress/decompress, encrypt/decrypt, encode/decode)

  • Has experimentado bugs causados por inputs específicos de casos edge (colecciones vacías, MAX_INT, caracteres especiales)

Cuándo NO Usar

  • El código depende fuertemente de I/O o tiene efectos secundarios — las propiedades son difíciles de enunciar y verificar
  • Los tests necesitan asertar comportamiento exacto para escenarios de negocio específicos — usa tests basados en ejemplos
  • La propiedad es demasiado compleja para enunciar formalmente (“se ve bien para un humano”)
  • El tiempo de ejecución importa — los property tests ejecutan cientos de iteraciones y pueden ser lentos

Implementación Paso a Paso

Python (Hypothesis)

from hypothesis import given, strategies as st, settings, example

# Propiedad básica: reversar 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):
    serialized = json.dumps(user)
    deserialized = json.loads(serialized)
    assert deserialized == user

# Testing de state machine
from hypothesis.stateful import RuleBasedStateMachine, rule, precondition

class CounterMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.value = 0

    @rule(n=st.integers(min_value=0))
    def add(self, n):
        self.value += n

    @rule(n=st.integers(min_value=0, max_value=self.value))
    def subtract(self, n):
        self.value -= n

    @precondition(lambda self: self.value > 0)
    @rule()
    def is_positive(self):
        assert self.value > 0

TestCounter = CounterMachine.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 con precondición
fc.assert(
  fc.property(
    fc.array(fc.integer()),
    (arr) => {
      const sorted = arr.slice().sort((a, b) => a - b);
      // Monotónico: cada elemento <= el siguiente
      for (let i = 1; i < sorted.length; i++) {
        if (sorted[i - 1] > sorted[i]) return false;
      }
      return sorted.length === arr.length;
    }
  )
);

// Model-based testing (state machine)
class ListModel {
  constructor() { this.items = []; }
  push(x) { this.items.push(x); }
  pop() { return this.items.pop(); }
  get length() { return this.items.length; }
}

fc.assert(
  fc.property(
    fc.commands([
      fc.integer().map(n => ({ type: 'push', value: n })),
      fc.constant({ type: 'pop' })
    ]),
    (cmds) => {
      const model = new ListModel();
      const sut = new MyList();
      fc.modelRun(() => ({ model, real: sut }), cmds);
    }
  )
);

// Shrink al caso de fallo mínimo
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    return myFunction(arr) >= 0;  # Falla en algún input
  })
);
// fast-check automáticamente hace shrink al array más pequeño que falla

Java (jqwik)

import net.jqwik.api.*;

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);
    }
}

// Stateful testing
class StackMachine {
    private final Stack<Integer> stack = new Stack<>();

    @Action
    void push(@ForAll int value) { stack.push(value); }

    @Action
    @Precondition("!stack.isEmpty()")
    void pop() { stack.pop(); }

    @Invariant
    boolean sizeIsNeverNegative() { return stack.size() >= 0; }
}

Lo que funciona

  • Comienza con propiedades, no con generadores. La parte difícil del property-based testing es encontrar la propiedad correcta (encode(decode(x)) == x), no escribir el generador.
  • Usa shrinking religiosamente. El valor del property-based testing es encontrar el caso de fallo mínimo. Asegúrate de que el shrinking de tu framework esté habilitado y sea útil.
  • Combina con tests basados en ejemplos. Las propiedades verifican invariantes; los ejemplos verifican escenarios de negocio específicos. Ambos son necesarios.
  • Mantén las propiedades puras. Una propiedad que escribe a base de datos o depende del tiempo actual no es reproducible y no puede hacerse shrink bien.
  • Usa una seed determinística en CI. Los property tests son aleatorios por naturaleza; una seed asegura que los fallos sean reproducibles entre ejecuciones.

Errores Comunes

  • Testear la implementación, no la especificación. Escribir property: sort(arr) == mySortFunction(arr) es tautológico y no encuentra bugs.
  • Propiedades demasiado débiles. length(f(x)) >= 0 siempre es verdadero y no provee valor. Las propiedades deben ser lo suficientemente fuertes para atrapar bugs reales.
  • Ignorar el output de shrinking. Un array de 100 elementos que falla es difícil de debuggear; el array shrink de 3 elementos es lo que deberías analizar.
  • Generadores lentos o no terminantes. Generar estructuras recursivas sin límites de profundidad puede causar loops infinitos durante la ejecución de tests.
  • Propiedades flaky debido a estado global. Una propiedad que modifica un contador a nivel de módulo falla impredeciblemente dependiendo del orden de ejecución.

Lectura Adicional

  • Documentación oficial: consulta la referencia actualizada del framework o herramienta utilizada.
  • Guías relacionadas: explora las guías de testing y property-based-testing para profundizar.
  • Patrones complementarios: revisa los patrones de diseño aplicables a tu stack tecnológico.
  • Postmortems públicos: estudia incidentes reales de equipos que enfrentaron problemas similares en producción.

Notas de Producción

  • Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
  • Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
  • Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
  • Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.

Puntos Clave

  • Aplica implementar property-based testing cuando necesites una solución práctica para tu caso de uso.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

Troubleshooting

  • Flaky tests: isolate shared state, time, and randomness. Make tests independent and deterministic; quarantine persistently flaky tests.
  • High coverage but bugs in production: coverage does not guarantee correctness. Add mutation testing, property-based tests, or contract tests.
  • Slow test suite: parallelize, mock slow dependencies, and avoid end-to-end tests for logic that can be unit tested.
  • Tests pass locally but fail in CI: check environment differences, timezone, locale, and dependency versions. Pin tool versions.
  • Debugging a failing integration test: Reset state before each test.

Errores Comunes en Producción

  • Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
  • Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
  • Codificar valores fijos que deberían ser configurables por entorno.
  • Olvidar agregar logging y monitoreo en cada paso.
  • Desplegar sin plan de rollback ni estrategia de backup probada.
  • Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
  • No documentar la versión y configuración usadas en producción.
  • Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.

Preguntas frecuentes

¿Qué es property-based testing?
En lugar de escribir entradas de ejemplo, defines propiedades que siempre deberían cumplirse para una función. El framework genera cientos de entradas aleatorias e intenta encontrar un contraejemplo.
¿Qué es el shrinking en PBT?
Cuando se encuentra un contraejemplo, el framework reduce la entrada al valor más pequeño posible que aún falla la propiedad. Esto hace el debugging mucho más rápido.
¿Cuándo PBT no es una buena opción?
PBT es menos útil para flujos de UI, máquinas de estado complejas o propiedades difíciles de expresar formalmente. Los tests basados en ejemplos siguen siendo valiosos para esos casos.