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.
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.
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)) >= 0siempre 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 }afc.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,numRunsotriesen el perfil de CI y deja las ejecuciones completas para un job nocturno.
Lectura Adicional
- Documentación de Hypothesis: estrategias, testing con estado y replay de la base de ejemplos en Python.
- Documentación de fast-check: arbitraries, testing basado en modelo y shrinking en TypeScript/JavaScript.
- Guía de usuario de jqwik: propiedades, providers y testing con estado mediante
ActionChainen la JVM. - Guía completa de property-based testing: la versión extensa de esta receta con los trade-offs de diseño.
- Proyecto acompañante ejecutable: los ejemplos de esta receta como proyectos Python, JavaScript y Java ejecutables.
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.
Recursos Relacionados
Generar Datos de Test
Cómo generar datos de test realistas y deterministas con Faker, factory-boy y generadores type-aware para suites de test confiables en Python, JavaScript y Java.
RecipeConfigurar Fixtures de Test
Cómo gestionar fixtures de test con patrones factory, hooks de setup/teardown y datos deterministas para tests unitarios e integración confiables en Python, JavaScript y Java.
RecipeImplementar Mutation Testing
Cómo usar mutation testing con MutPy, Stryker y PIT para evaluar si tus tests realmente asertan comportamiento o simplemente ejecutan código.
GuideProperty-Based Testing: Hypothesis, fast-check, QuickCheck
Dominá property-based testing con Hypothesis (Python), fast-check (TypeScript) y principios de QuickCheck. Generá test cases automáticamente, encontrá edge cases y shrinkéá failures.
RecipeProperty-Based Testing con Hypothesis
Cómo usar Hypothesis para property-based testing en Python, generando cientos de casos de test automáticamente desde strategies en lugar de escribirlos a mano.