Skip to content
SP StackPractices
intermediate Por StackPractices

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

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

Mejores Prácticas

  • 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 efectivo.
  • 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 efectivamente.
  • 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.

Recursos Relacionados