Property-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.
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.
Introducción
Property-based testing invierte el modelo de testing tradicional. En vez de escribir test cases individuales con inputs y outputs específicos, definís properties — invariantes que deberían valer para todos los inputs válidos — y el framework genera cientos o miles de test cases automáticamente. Cuando un test falla, el framework shrinkéa el input que falla al caso más chico posible que reproduce el bug. A continuación: Hypothesis para Python, fast-check para TypeScript/JavaScript y los principios subyacentes de QuickCheck que alimentan todos los frameworks de property-based testing.
Conceptos Core
Example-based vs. property-based testing
Example-based (tradicional):
test_add(2, 3) == 5
test_add(0, 0) == 0
test_add(-1, 1) == 0
→ Pensás edge cases manualmente. Te faltan algunos.
Property-based:
for all integers a, b:
add(a, b) == add(b, a) — commutativity
add(a, 0) == a — identity
add(add(a, b), c) == add(a, add(b, c)) — associativity
→ El framework genera cientos de inputs. Encuentra edge cases que te faltaron.
El ciclo de property-based testing
- Generate: Creá random inputs dentro de constraints definidas
- Execute: Corré la function bajo test con esos inputs
- Verify: Checkeá si la property vale
- Shrink: En failure, reducí el input al caso minimal que reproduce
- Report: Presentá el counterexample shrunkéado
Hypothesis (Python)
Instalación
pip install hypothesis
# O con pytest integration (recomendado)
pip install hypothesis pytest
Properties básicas
# tests/test_string_properties.py — Basic property tests
from hypothesis import given, strategies as st
@given(st.text())
def test_string_uppercase_is_uppercase(s):
"""Uppercase de cualquier string no contiene lowercase characters."""
assert s.upper().isupper() or not s
@given(st.text())
def test_string_reversal_is_inverse(s):
"""Revertir un string dos veces da back el original."""
assert s[::-1][::-1] == s
@given(st.lists(st.integers()))
def test_list_sort_preserves_length(lst):
"""Sortear una list no cambia su length."""
assert len(sorted(lst)) == len(lst)
@given(st.lists(st.integers()))
def test_list_sort_idempotent(lst):
"""Sortear una list ya sorteada da la misma list."""
sorted_lst = sorted(lst)
assert sorted(sorted_lst) == sorted_lst
@given(st.lists(st.integers()))
def test_list_max_is_in_list(lst):
"""El max de una list siempre es un elemento de la list."""
if lst:
assert max(lst) in lst
Composite strategies
# tests/test_user_properties.py — Composite strategies
from hypothesis import given, strategies as st, composite
@composite
def valid_email(draw):
"""Generá valid email addresses."""
local = draw(st.text(min_size=1, max_size=20, alphabet=st.characters(whitelist_categories=("Ll", "Nd"))))
domain = draw(st.text(min_size=1, max_size=10, alphabet=st.characters(whitelist_categories=("Ll", "Nd"))))
tld = draw(st.sampled_from(["com", "org", "net", "io", "dev"]))
return f"{local}@{domain}.{tld}"
@composite
def user_strategy(draw):
"""Generá user objects con valid fields."""
return {
"id": draw(st.integers(min_value=1, max_value=1000000)),
"name": draw(st.text(min_size=1, max_size=50)),
"email": draw(valid_email()),
"age": draw(st.integers(min_value=0, max_value=150)),
"is_active": draw(st.booleans()),
}
@given(user_strategy())
def test_user_serialization_roundtrip(user):
"""Serializar y deserializar un user preserva todos los fields."""
serialized = json.dumps(user)
deserialized = json.loads(serialized)
assert deserialized == user
@given(user_strategy())
def test_user_email_contains_at(user):
"""Cada user email generado contiene @."""
assert "@" in user["email"]
Assumptions y conditional properties
# tests/test_conditional_properties.py — Filtrando inputs
from hypothesis import given, strategies as st, assume
@given(st.lists(st.integers()))
def test_binary_search_finds_element(lst):
"""Binary search encuentra cualquier elemento en una sorted list."""
assume(len(lst) > 0) # Skipeá empty lists
sorted_lst = sorted(lst)
target = sorted_lst[len(sorted_lst) // 2] # Pick middle element
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
result = binary_search(sorted_lst, target)
assert result != -1
assert sorted_lst[result] == target
@given(st.text())
def test_only_alphanumeric_sort(s):
"""Sortear funciona correctamente para alphanumeric strings."""
assume(s.isalnum()) # Solo testeá alphanumeric strings
assert "".join(sorted(s)) == "".join(sorted("".join(sorted(s))))
Stateful testing
# tests/test_stack_properties.py — Stateful property testing
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant, initialize
from hypothesis import given, strategies as st
class StackMachine(RuleBasedStateMachine):
"""Testeá una stack implementation con stateful properties."""
def __init__(self):
super().__init__()
self.stack = []
@rule(value=st.integers())
def push(self, value):
self.stack.append(value)
@rule()
def pop(self):
if self.stack:
self.stack.pop()
@invariant()
def stack_size_non_negative(self):
assert len(self.stack) >= 0
@invariant()
def pop_returns_last_pushed(self):
if self.stack:
top = self.stack[-1]
assert self.stack.pop() == top
self.stack.append(top) # Restore para other rules
TestStack = StackMachine.TestCase
Settings y configuration
# tests/test_configured.py — Hypothesis settings
from hypothesis import given, strategies as st, settings, HealthCheck
@given(st.lists(st.integers(), min_size=1))
@settings(
max_examples=500, # Generá hasta 500 test cases
deadline=2000, # 2 second timeout per test
suppress_health_check=[HealthCheck.too_slow],
print_blob=True, # Printá el failing input como blob
)
def test_large_list_processing(lst):
"""Procesá large lists dentro del time budget."""
result = process_list(lst)
assert len(result) == len(lst)
fast-check (TypeScript/JavaScript)
Instalación
npm install -D fast-check vitest
Properties básicas
// tests/properties/string.test.ts — Basic property tests con fast-check
import { describe, it, expect } from "vitest";
import fc from "fast-check";
describe("String properties", () => {
it("uppercase contains no lowercase characters", () => {
fc.assert(fc.property(fc.string(), (s) => {
expect(s.toUpperCase().match(/[a-z]/)).toBeNull();
}));
});
it("reversing twice returns the original", () => {
fc.assert(fc.property(fc.string(), (s) => {
expect(s.split("").reverse().reverse().join("")).toBe(s);
}));
});
it("concatenation length is sum of lengths", () => {
fc.assert(fc.property(fc.string(), fc.string(), (a, b) => {
expect((a + b).length).toBe(a.length + b.length);
}));
});
});
describe("Number properties", () => {
it("addition is commutative", () => {
fc.assert(fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(a + b).toBe(b + a);
}));
});
it("addition is associative", () => {
fc.assert(fc.property(fc.integer(), fc.integer(), fc.integer(), (a, b, c) => {
expect((a + b) + c).toBe(a + (b + c));
}));
});
it("multiplication distributes over addition", () => {
fc.assert(fc.property(fc.integer(), fc.integer(), fc.integer(), (a, b, c) => {
expect(a * (b + c)).toBe(a * b + a * c);
}));
});
});
Custom arbitraries
// tests/properties/user.test.ts — Custom arbitraries
import { describe, it, expect } from "vitest";
import fc from "fast-check";
const validEmail = fc.tuple(
fc.string({ minLength: 1 }).map((s) => s.replace(/[^a-zA-Z0-9]/g, "a")),
fc.string({ minLength: 1 }).map((s) => s.replace(/[^a-zA-Z0-9]/g, "a")),
fc.constantFrom("com", "org", "net", "io", "dev")
).map(([local, domain, tld]) => `${local}@${domain}.${tld}`);
const userArbitrary = fc.record({
id: fc.integer({ min: 1, max: 1000000 }),
name: fc.string({ minLength: 1, maxLength: 50 }),
email: validEmail,
age: fc.integer({ min: 0, max: 150 }),
isActive: fc.boolean(),
});
describe("User properties", () => {
it("serialization roundtrip preserves data", () => {
fc.assert(fc.property(userArbitrary, (user) => {
const serialized = JSON.stringify(user);
const deserialized = JSON.parse(serialized);
expect(deserialized).toEqual(user);
}));
});
it("email contains @", () => {
fc.assert(fc.property(userArbitrary, (user) => {
expect(user.email).toContain("@");
}));
});
});
Stateful testing con fast-check
// tests/properties/stack.test.ts — Stateful property testing
import { describe, it, expect } from "vitest";
import fc from "fast-check";
type StackCommand =
| { type: "push"; value: number }
| { type: "pop" };
const stackCommands = fc.array(
fc.oneof(
fc.record({ type: fc.constant("push"), value: fc.integer() }),
fc.record({ type: fc.constant("pop") })
)
);
describe("Stack stateful properties", () => {
it("stack size is always non-negative", () => {
fc.assert(fc.property(stackCommands, (commands) => {
const stack: number[] = [];
for (const cmd of commands) {
if (cmd.type === "push") {
stack.push(cmd.value);
} else if (stack.length > 0) {
stack.pop();
}
expect(stack.length).toBeGreaterThanOrEqual(0);
}
}));
});
it("pop returns last pushed value (LIFO)", () => {
fc.assert(fc.property(stackCommands, (commands) => {
const stack: number[] = [];
for (const cmd of commands) {
if (cmd.type === "push") {
stack.push(cmd.value);
} else if (stack.length > 0) {
const top = stack[stack.length - 1];
expect(stack.pop()).toBe(top);
}
}
}));
});
});
Shrinking y debugging
// tests/properties/shrinking.test.ts — Entendiendo shrinking
import { describe, it, expect } from "vitest";
import fc from "fast-check";
describe("Shrinking behavior", () => {
it("finds minimal failing case", () => {
// Esto va a fallar y shrinkéar al caso minimal
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
// Property: sorted array has no element greater than the next
const sorted = [...arr].sort((a, b) => a - b);
for (let i = 0; i < sorted.length - 1; i++) {
expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
}
}),
{ verbose: 2 } // Mostrá shrinking steps
);
});
it("with custom seed for reproducibility", () => {
fc.assert(
fc.property(fc.integer(), (n) => {
expect(Math.abs(n)).toBeGreaterThanOrEqual(0);
}),
{ seed: 12345 } // Test runs reproducibles
);
});
});
Patrones Prácticos de Properties
Roundtrip properties
# Python: serialize → deserialize → compare
@given(st.text())
def test_json_roundtrip(s):
"""JSON serialization es un roundtrip para strings."""
assert json.loads(json.dumps(s)) == s
@given(st.lists(st.integers()))
def test_sort_roundtrip(lst):
"""Sortear es idempotent."""
assert sorted(sorted(lst)) == sorted(lst)
// TypeScript: encode → decode → compare
it("base64 roundtrip", () => {
fc.assert(fc.property(fc.string(), (s) => {
const encoded = btoa(s);
const decoded = atob(encoded);
expect(decoded).toBe(s);
}));
});
Invariant properties
@given(st.lists(st.integers(), min_size=1))
def test_max_is_geq_all(lst):
"""El max de una list es >= todos los elementos."""
m = max(lst)
for x in lst:
assert m >= x
@given(st.lists(st.integers(), min_size=1))
def test_sum_equals_max_times_count(lst):
"""Sum de una list de valores idénticos equals value * count."""
value = lst[0]
assume(all(x == value for x in lst))
assert sum(lst) == value * len(lst)
Oracle properties
@given(st.lists(st.integers()))
def test_custom_sort_matches_builtin(lst):
"""Custom sort produce mismo resultado que Python built-in sort."""
custom_sorted = my_custom_sort(lst[:])
builtin_sorted = sorted(lst)
assert custom_sorted == builtin_sorted
Stateful properties
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
class DictStateMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.dict = {}
self.model = {}
@rule(key=st.text(), value=st.integers())
def set_value(self, key, value):
self.dict[key] = value
self.model[key] = value
@rule(key=st.text())
def get_value(self, key):
if key in self.model:
assert self.dict[key] == self.model[key]
@invariant()
def sizes_match(self):
assert len(self.dict) == len(self.model)
TestDict = DictStateMachine.TestCase
Integración con CI
GitHub Actions con Hypothesis
# .github/workflows/property-tests.yml
name: Property Tests
on: [push, pull_request]
jobs:
property-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install hypothesis pytest
- run: pytest tests/properties/ -v --hypothesis-show-statistics
GitHub Actions con fast-check
# .github/workflows/property-tests.yml
name: Property Tests
on: [push, pull_request]
jobs:
property-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx vitest run tests/properties/
Best Practices
-
For a deeper guide, see Implement Property-Based Testing.
-
Empezá con properties simples — commutativity, associativity, identity
-
Usá roundtrip properties para serialization — encode then decode debería devolver original
-
Usá oracle properties cuando existe una reference implementation — compará contra built-in
-
Seteá
max_examplesmás alto para código crítico — 1000 en vez del default 100 -
Usá
assumesparingly — demasiadas assumptions reducen test coverage -
Pineá el random seed en CI — failures reproducibles
-
Combiná con example-based tests — properties encuentran edge cases, examples documentan behavior
-
Usá stateful testing para stateful systems — databases, caches, queues
-
Mirá el shrinking output — el counterexample minimal revela el root cause
-
No testees framework code — testeá tu business logic, no el sort del language
Common Mistakes
- Properties demasiado débiles:
sort(lst) has same length as lst— true pero no testea sorting. Agregásort(lst) is sorted. - Properties demasiado fuertes:
add(a, b) > 0— falla para negative numbers. Usáadd(a, 0) == aen vez. - No usar
assume: testear properties que solo valen para algunos inputs sin filtrar. Usáassume(condition). - Ignorar shrinking: el caso shrunkéado es el output más valioso. Leelo para entender el bug.
- Testear demasiado por property: una property por test function. Si falla, sabés exactamente qué invariante breakó.
FAQ
¿Qué es property-based testing?
Un approach de testing donde definís invariantes (properties) que deberían valer para todos los inputs válidos, y un framework genera cientos de test cases automáticamente. Cuando un test falla, el framework shrinkéa el input al caso minimal que reproduce.
¿Cómo se diferencia de fuzzing?
Fuzzing se enfoca en encontrar crashes y security issues tirando data random a un programa. Property-based testing verifica que invariantes específicas valgan, con input generation estructurada y shrinking para debugging.
¿Qué es shrinking?
Cuando una property test falla con un input complejo, el framework reduce sistemáticamente el input al caso más chico que still falla. Esto hace debugging mucho más fácil — en vez de una list de 500 elementos, obtenés una de 2 que triggera el mismo bug.
¿Cuándo debería usar property-based testing?
Usalo para pure functions, data transformations, serialization, parsers y stateful systems. Es especialmente valioso cuando tenés una reference implementation para comparar (oracle properties).
¿Cuáles son las limitaciones?
Las properties pueden ser difíciles de definir para UI code, side-effect-heavy functions y systems con preconditions complejas. Property tests son más lentos que unit tests por correr cientos de iteraciones. Usalos junto a, no en vez de, example-based tests.
Recursos Relacionados
Test-Driven Development (TDD) — A Practical Workflow
Learn TDD step by step: write a failing test, make it pass, refactor. Red-Green-Refactor with real examples in Python, JavaScript, and Java.
GuideSoftware Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, and end-to-end tests.
GuidePytest in Production Guide
Master pytest for production codebases: advanced fixtures, plugins, custom markers, parametrized tests, parallel execution with pytest-xdist, and CI integration.
RecipeProperty-Based Testing with Hypothesis
How to use Hypothesis for property-based testing in Python, generating hundreds of test cases automatically from strategies instead of writing them by hand.
RecipeImplement Property-Based Testing
How to write property-based tests with Hypothesis, fast-check, and jqwik that generate thousands of inputs to find edge cases traditional tests miss.