Pruebas basadas en propiedades: los errores que se te escapan
Cómo las pruebas basadas en propiedades generan cientos de entradas aleatorias para verificar invariantes y hallar casos límite, con fast-check.

Las pruebas unitarias verifican que unas entradas específicas produzcan unas salidas específicas. Eliges tres o cuatro ejemplos, compruebas los resultados y das el trabajo por hecho. Pero los errores que llegan a producción no están en los ejemplos que imaginaste, sino en las entradas en las que no pensaste: cadenas vacías, números negativos, casos límite de Unicode, arreglos con un solo elemento, objetos con combinaciones de propiedades inesperadas.
Las pruebas basadas en propiedades invierten el enfoque. En lugar de especificar ejemplos, defines propiedades —invariantes que deben cumplirse para cualquier entrada válida— y el framework de pruebas genera cientos de entradas aleatorias para intentar romperlas.
De ejemplos a propiedades
El cambio central va de "esta entrada específica produce esta salida específica" a "para todas las entradas válidas, esta propiedad se cumple".
// ❌ Example-based test — picks a few known inputs
describe("sort", () => {
it("sorts numbers ascending", () => {
expect(sort([3, 1, 2])).toEqual([1, 2, 3]);
});
it("handles empty arrays", () => {
expect(sort([])).toEqual([]);
});
it("handles single element", () => {
expect(sort([5])).toEqual([5]);
});
// What about negative numbers? Duplicates?
// Very large arrays? NaN? Infinity?
});// ✅ Property-based test — verifies invariants for any input
import * as fc from "fast-check";
describe("sort", () => {
it("output length equals input length", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(sort(arr)).toHaveLength(arr.length);
})
);
});
it("output is ordered", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sort(arr);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i]).toBeGreaterThanOrEqual(
sorted[i - 1]
);
}
})
);
});
it("output contains the same elements", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sort(arr);
expect([...sorted].sort()).toEqual(
[...arr].sort()
);
})
);
});
it("is idempotent", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(sort(sort(arr))).toEqual(sort(arr));
})
);
});
});Esas cuatro propiedades —preservación de la longitud, orden, preservación de los elementos e idempotencia— especifican por completo lo que debe hacer una función de ordenamiento. El framework genera arreglos de longitudes variables, con números negativos, duplicados, ceros y valores grandes. Si alguna combinación rompe una propiedad, se informa el caso mínimo que falla.
Arbitraries personalizados para tipos de dominio
Las aplicaciones reales no operan sobre números enteros y cadenas sin más. Las pruebas basadas en propiedades brillan cuando construyes generadores personalizados (llamados "arbitraries" en fast-check) que producen objetos de dominio realistas.
// Build arbitraries that match your domain types
interface Money {
amount: number;
currency: "USD" | "EUR" | "GBP";
}
const moneyArb: fc.Arbitrary<Money> = fc.record({
amount: fc.integer({ min: 0, max: 1_000_000 }),
currency: fc.constantFrom("USD", "EUR", "GBP"),
});
interface OrderItem {
name: string;
price: Money;
quantity: number;
}
const orderItemArb: fc.Arbitrary<OrderItem> = fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
price: moneyArb,
quantity: fc.integer({ min: 1, max: 99 }),
});
interface Order {
items: OrderItem[];
discount: number;
}
const orderArb: fc.Arbitrary<Order> = fc.record({
items: fc.array(orderItemArb, {
minLength: 1,
maxLength: 20,
}),
discount: fc.integer({ min: 0, max: 100 }),
});
// Now test order total calculation with realistic inputs
describe("calculateOrderTotal", () => {
it("total is never negative", () => {
fc.assert(
fc.property(orderArb, (order) => {
const total = calculateOrderTotal(order);
expect(total.amount).toBeGreaterThanOrEqual(0);
})
);
});
it("total without discount >= total with discount", () => {
fc.assert(
fc.property(orderArb, (order) => {
const withDiscount = calculateOrderTotal(order);
const withoutDiscount = calculateOrderTotal({
...order,
discount: 0,
});
expect(withoutDiscount.amount).toBeGreaterThanOrEqual(
withDiscount.amount
);
})
);
});
});Shrinking: cómo encontrar los casos mínimos que fallan
Cuando una propiedad falla, la entrada generada en bruto suele ser compleja: un arreglo de 50 elementos con números grandes. Fast-check reduce ("shrinks") automáticamente la entrada que falla para encontrar el caso más pequeño que aún falla.
// A buggy function
function removeDuplicates<T>(arr: T[]): T[] {
// Bug: uses indexOf which fails with NaN
return arr.filter(
(item, index) => arr.indexOf(item) === index
);
}
describe("removeDuplicates", () => {
it("output has no duplicates", () => {
fc.assert(
fc.property(
fc.array(fc.oneof(fc.integer(), fc.constant(NaN))),
(arr) => {
const result = removeDuplicates(arr);
const uniqueSet = new Set(result);
// NaN !== NaN, so Set handles it correctly
// but our function doesn't
expect(result.length).toBe(uniqueSet.size);
}
)
);
});
});
// fast-check output after shrinking:
// Property failed after 12 tests
// Shrunk 5 time(s)
// Counterexample: [[NaN, NaN]]
// Because indexOf(NaN) is always -1, NaN is never
// "found" so every NaN passes the filterEl contraejemplo reducido [NaN, NaN] es mucho más útil que cualquier arreglo aleatorio que haya provocado originalmente el fallo. El shrinking transforma "tu código falla con este arreglo de 47 elementos" en "tu código falla con [NaN, NaN]", señalando de inmediato el error.
Pruebas de propiedades con estado
Más allá de las funciones puras, las pruebas basadas en propiedades pueden verificar sistemas con estado generando secuencias de operaciones y comprobando que los invariantes se cumplen después de cada paso.
// Model-based testing: compare implementation against
// a simple model
class AccountModel {
balance = 0;
deposit(amount: number) {
this.balance += amount;
}
withdraw(amount: number) {
if (amount <= this.balance) {
this.balance -= amount;
}
}
}
// Command pattern for stateful testing
class DepositCommand implements fc.Command<
AccountModel,
BankAccount
> {
constructor(readonly amount: number) {}
check() {
return true;
}
run(model: AccountModel, real: BankAccount) {
model.deposit(this.amount);
real.deposit(this.amount);
expect(real.getBalance()).toBe(model.balance);
}
toString() {
return `deposit(${this.amount})`;
}
}
class WithdrawCommand implements fc.Command<
AccountModel,
BankAccount
> {
constructor(readonly amount: number) {}
check(model: AccountModel) {
return this.amount <= model.balance;
}
run(model: AccountModel, real: BankAccount) {
model.withdraw(this.amount);
real.withdraw(this.amount);
expect(real.getBalance()).toBe(model.balance);
}
toString() {
return `withdraw(${this.amount})`;
}
}
// Generate random command sequences
const commandArb = fc.commands([
fc.integer({ min: 1, max: 10000 }).map(
(n) => new DepositCommand(n)
),
fc.integer({ min: 1, max: 10000 }).map(
(n) => new WithdrawCommand(n)
),
]);
describe("BankAccount", () => {
it("matches model after any operation sequence", () => {
fc.assert(
fc.property(commandArb, (cmds) => {
const setup = () => ({
model: new AccountModel(),
real: new BankAccount(),
});
fc.modelRun(setup, cmds);
})
);
});
});Esto genera secuencias aleatorias de depósitos y retiros, ejecutando cada uno tanto contra el modelo simple como contra la implementación real, y verificando que se mantengan sincronizados. Si la BankAccount real tiene un error de redondeo o un error de desfase (off-by-one) que solo aparece tras una secuencia específica de operaciones, esta prueba lo encontrará y reducirá la secuencia al caso mínimo que lo reproduce.
Integración con las suites de pruebas existentes
Las pruebas basadas en propiedades complementan a las pruebas basadas en ejemplos. No las reemplazan: sigues necesitando ejemplos concretos que documenten el comportamiento esperado y sirvan como pruebas de regresión para errores conocidos.
// ❌ Replacing all tests with property tests
// Loses documentation value and regression specificity
// ✅ Add property tests alongside examples
describe("parseEmail", () => {
// Example tests: document behavior, serve as regression
it("parses standard email", () => {
expect(parseEmail("user@example.com")).toEqual({
local: "user",
domain: "example.com",
});
});
it("rejects email without @", () => {
expect(parseEmail("invalid")).toBeNull();
});
// Property tests: find edge cases you didn't imagine
it("round-trips valid emails", () => {
const emailArb = fc
.tuple(
fc.stringMatching(/^[a-z][a-z0-9.]{0,20}$/),
fc.stringMatching(/^[a-z][a-z0-9]{1,10}$/),
fc.stringMatching(/^[a-z]{2,6}$/)
)
.map(
([local, domain, tld]) =>
`${local}@${domain}.${tld}`
);
fc.assert(
fc.property(emailArb, (email) => {
const parsed = parseEmail(email);
if (parsed) {
expect(`${parsed.local}@${parsed.domain}`).toBe(
email
);
}
})
);
});
it("never throws on any string input", () => {
fc.assert(
fc.property(fc.string(), (input) => {
// Should return result or null, never throw
expect(() => parseEmail(input)).not.toThrow();
})
);
});
});Conclusiones clave
Las pruebas basadas en propiedades trasladan el foco de los ejemplos concretos a los invariantes universales: en lugar de preguntar "¿esta entrada produce esta salida?", preguntas "¿esta propiedad se cumple para todas las entradas válidas?". Fast-check genera cientos de entradas aleatorias en cada ejecución de prueba y reduce automáticamente los casos que fallan hasta el contraejemplo mínimo, convirtiendo "falla con un arreglo de 47 elementos" en "falla con [NaN, NaN]". Los arbitraries personalizados te permiten generar objetos de dominio realistas —pedidos, usuarios, transacciones— para que las pruebas de propiedades ejerciten las mismas formas de datos que tu aplicación maneja en producción. Las pruebas con estado basadas en modelos verifican que las secuencias de operaciones sobre una implementación real coincidan con un modelo simplificado, detectando errores que solo surgen a partir de órdenes específicos de operaciones. Las pruebas de propiedades complementan, en lugar de reemplazar, a las pruebas basadas en ejemplos: los ejemplos documentan el comportamiento esperado y sirven como anclas de regresión, mientras que las propiedades exploran el vasto espacio de entradas que no se te ocurrió comprobar manualmente.


