Pruebas basadas en propiedades en TypeScript con Fast-Check
Cómo las pruebas basadas en propiedades encuentran casos límite que tus tests no ven: guía práctica con fast-check para generar entradas e invariantes.

Las pruebas unitarias tradicionales verifican el comportamiento para entradas específicas que tú eliges. Las pruebas basadas en propiedades le dan la vuelta a esto: describes las propiedades que tu código debe cumplir, y el framework genera cientos o miles de entradas aleatorias para encontrar violaciones. Cuando encuentra una, reduce la entrada al caso reproducible más pequeño (shrinking).
El resultado: tus pruebas encuentran casos límite en los que nunca pensaste. Errores de desfase por uno (off-by-one), entradas vacías, casos límite de Unicode, desbordamientos de enteros, y combinaciones de valores límite que requerirían miles de pruebas escritas a mano para cubrir.
Por qué las pruebas basadas en ejemplos no detectan errores
Piensa en una función que ordena un array. Podrías escribir tres pruebas basadas en ejemplos: array vacío, ya ordenado, y en orden inverso. Eso cubre tres casos de un número infinito de posibilidades.
// ❌ Example-based tests — cover only cases you think of
import { describe, it, expect } from 'vitest';
import { customSort } from './sort';
describe('customSort', () => {
it('handles empty arrays', () => {
expect(customSort([])).toEqual([]);
});
it('sorts already sorted arrays', () => {
expect(customSort([1, 2, 3])).toEqual([1, 2, 3]);
});
it('sorts reverse arrays', () => {
expect(customSort([3, 2, 1])).toEqual([1, 2, 3]);
});
});
// These pass, but what about [NaN, -0, Infinity]?
// What about arrays with 10,000 duplicate elements?
// What about negative numbers mixed with zero?// ✅ Property-based tests — describe what MUST be true for ALL inputs
import { describe, it } from 'vitest';
import * as fc from 'fast-check';
import { customSort } from './sort';
describe('customSort properties', () => {
it('output length equals input length', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
return sorted.length === arr.length;
})
);
});
it('output is sorted in ascending order', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] < sorted[i - 1]) return false;
}
return true;
})
);
});
it('output contains the same elements as input', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
const inputCounts = countElements(arr);
const outputCounts = countElements(sorted);
return mapsEqual(inputCounts, outputCounts);
})
);
});
});Tres propiedades — preservación de la longitud, el orden y preservación de los elementos — cubren el contrato de comportamiento completo de una función de ordenamiento para cualquier array de enteros, no solo los tres ejemplos que se te ocurrió escribir.
Primeros pasos con Fast-Check
Fast-check es la biblioteca de pruebas basadas en propiedades más popular para TypeScript. Se integra con cualquier ejecutor de pruebas: Vitest, Jest, Mocha.
npm install -D fast-checkimport * as fc from 'fast-check';
// Basic arbitraries — generators for random values
fc.integer(); // Random integers
fc.string(); // Random strings (including Unicode)
fc.boolean(); // true or false
fc.float(); // Random floating-point numbers
fc.array(fc.integer()); // Arrays of random integers
fc.date(); // Random Date objects
// Constrained arbitraries
fc.integer({ min: 1, max: 100 }); // Integers between 1 and 100
fc.string({ minLength: 1, maxLength: 50 }); // Non-empty strings up to 50 chars
fc.array(fc.integer(), { minLength: 1 }); // Non-empty integer arrays
// Composing custom arbitraries for domain objects
const userArbitrary = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
age: fc.integer({ min: 18, max: 120 }),
isActive: fc.boolean(),
});
// Generates random User objects with valid structureProbando lógica de negocio real
Las pruebas basadas en propiedades brillan especialmente en lógica de negocio con invariantes complejas. Aquí tienes un ejemplo que prueba una calculadora de descuentos:
interface CartItem {
productId: string;
price: number;
quantity: number;
}
interface DiscountResult {
subtotal: number;
discount: number;
total: number;
}
function calculateDiscount(items: CartItem[], couponPercent: number): DiscountResult {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discount = subtotal * (couponPercent / 100);
const total = subtotal - discount;
return { subtotal, discount, total };
}
// Property-based tests for the discount calculator
describe('calculateDiscount', () => {
const cartItemArb = fc.record({
productId: fc.uuid(),
price: fc.float({ min: 0.01, max: 10000, noNaN: true }),
quantity: fc.integer({ min: 1, max: 100 }),
});
const cartArb = fc.array(cartItemArb, { minLength: 1, maxLength: 20 });
const couponArb = fc.float({ min: 0, max: 100, noNaN: true });
it('total is never negative', () => {
fc.assert(
fc.property(cartArb, couponArb, (items, coupon) => {
const result = calculateDiscount(items, coupon);
return result.total >= 0;
})
);
});
it('total + discount equals subtotal', () => {
fc.assert(
fc.property(cartArb, couponArb, (items, coupon) => {
const result = calculateDiscount(items, coupon);
return Math.abs(result.total + result.discount - result.subtotal) < 0.01;
})
);
});
it('zero coupon means no discount', () => {
fc.assert(
fc.property(cartArb, (items) => {
const result = calculateDiscount(items, 0);
return result.discount === 0 && result.total === result.subtotal;
})
);
});
it('100% coupon means zero total', () => {
fc.assert(
fc.property(cartArb, (items) => {
const result = calculateDiscount(items, 100);
return Math.abs(result.total) < 0.01;
})
);
});
});Shrinking: cómo encontrar fallos mínimos
Cuando fast-check encuentra una entrada que falla, automáticamente le aplica shrinking hasta llegar a la entrada más pequeña que todavía provoca el fallo. Esto hace que depurar sea muchísimo más fácil.
// Imagine a bug in a string processing function
function processTitle(input: string): string {
// Bug: crashes on strings containing null characters
return input.trim().toLowerCase().replace(/\s+/g, '-');
}
// Fast-check might initially find failure with:
// "a\u0000bCdEf\u0000gh..." (a long, complex string)
// After shrinking, it reports the minimal case:
// "\u0000" (single null character)
// The shrunk case makes the bug obvious:
// .trim() doesn't handle null characters as expected
// You can configure shrinking behavior:
fc.assert(
fc.property(fc.string(), (input) => {
const result = processTitle(input);
return typeof result === 'string' && result.length >= 0;
}),
{
numRuns: 1000, // Run 1000 random inputs
seed: 42, // Reproducible randomness
endOnFailure: true // Stop at first failure
}
);// ❌ No seed — failures may not reproduce
fc.assert(fc.property(fc.string(), validator));
// If this fails, you might not be able to reproduce it
// ✅ Use seeds for reproducibility — fast-check logs the seed on failure
fc.assert(
fc.property(fc.string(), validator),
{ seed: 12345 }
);
// Failure output includes: "Seed: 12345"
// Re-run with same seed → same sequence of inputs → same failureCombinando con pruebas basadas en ejemplos
Las pruebas basadas en propiedades no reemplazan a las pruebas basadas en ejemplos: las complementan. Usa ejemplos para documentar y para los casos límite que ya conoces. Usa propiedades para descubrir los casos límite que no conoces.
describe('parseEmail', () => {
// Example-based: documents known behavior and specific edge cases
it('parses standard email', () => {
expect(parseEmail('user@example.com')).toEqual({
local: 'user',
domain: 'example.com',
});
});
it('rejects email without @', () => {
expect(parseEmail('invalid')).toBeNull();
});
// Property-based: finds unknown edge cases
it('round-trips valid emails', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const parsed = parseEmail(email);
if (parsed === null) return false; // Valid email should parse
return `${parsed.local}@${parsed.domain}` === email.toLowerCase();
})
);
});
it('never throws on any string input', () => {
fc.assert(
fc.property(fc.string(), (input) => {
// Should return result or null, never throw
const result = parseEmail(input);
return result === null || (typeof result.local === 'string' && typeof result.domain === 'string');
})
);
});
});Puntos clave
- Las pruebas basadas en propiedades describen invariantes — lo que debe cumplirse para todas las entradas, no solo las que tú elegiste
- Fast-check genera cientos de entradas aleatorias y aplica shrinking automáticamente a los fallos hasta obtener casos mínimos reproducibles
- Propiedades básicas para probar: idempotencia, round-tripping, preservación de invariantes, conmutatividad, y "nunca lanza una excepción"
- Usa arbitraries con restricciones para generar objetos válidos para el dominio —
fc.record,fc.integer({ min, max }),fc.emailAddress() - Combina con pruebas basadas en ejemplos — los ejemplos documentan el comportamiento conocido, las propiedades descubren casos límite desconocidos
- Configura un seed para reproducibilidad — fast-check registra el seed en cada fallo para que puedas reproducir exactamente la misma secuencia


