Las pruebas tradicionales basadas en ejemplos (Example-Based Testing) adolecen del sesgo de confirmación (Confirmation Bias): el ingeniero tiende a verificar únicamente los casos frontera que anticipó durante el diseño del código. En consecuencia, anomalías críticas —arrays vacíos, pares suplentes de Unicode, cero negativo (-0), NaN, pérdida de precisión numérica o caracteres de control— terminan alcanzando producción inadvertidamente.
El enfoque Property-Based Testing (PBT) revoluciona este paradigma. El desarrollador establece:
fc.string(), fc.integer(), fc.record()).reverse(reverse(list)) === list).\0 o el valor -101).+-------------------------------------------------------------------------+
| Property-Based Testing Loop (fast-check) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------+
| Arbitrary Generator (100+ runs) |
| fc.record({ id: fc.uuid(), ... }) |
+-------------------------------------+
|
v
+-------------------------------------+
| Execute Invariant Test |
| assert(isInvariantHold(x)) |
+-------------------------------------+
|
+---------------+---------------+
| |
[Todas las pruebas pasan] [La prueba falla]
| |
v v
+-----------------+ +-----------------+
| PASSED (100) | | SHRINKING LOOP |
+-----------------+ | Reducción input |
+-----------------+
|
v
+-----------------+
| Min Counter-ex: |
| e.g. input: "" |
+-----------------+parse(serialize(x)) === x.clean(clean(x)) === clean(x).0 <= applyDiscount(price, rate) <= price.El principio de reversibilidad Roundtrip establece que codificar y decodificar consecutivamente una estructura restaura el estado original sin pérdidas de datos.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
export interface QueryState {
filter: string;
page: number;
isActive: boolean;
}
export function serializeQuery(state: QueryState): string {
const params = new URLSearchParams();
params.set('filter', state.filter);
params.set('page', state.page.toString());
params.set('isActive', state.isActive ? '1' : '0');
return params.toString();
}
export function parseQuery(queryString: string): QueryState {
const params = new URLSearchParams(queryString);
const filter = params.get('filter') ?? '';
const pageRaw = parseInt(params.get('page') ?? '1', 10);
const page = Number.isNaN(pageRaw) ? 1 : pageRaw;
const isActive = params.get('isActive') === '1';
return { filter, page, isActive };
}
describe('Roundtrip: serializeQuery <-> parseQuery', () => {
it('garantiza la restauración exacta del estado tras codificar y decodificar', () => {
// Generador Arbitrary para modelar instancias válidas de QueryState
const queryArbitrary = fc.record<QueryState>({
// Cadenas arbitrarias con espacios, caracteres Unicode y símbolos URI
filter: fc.string(),
// Rango natural de paginación entre 1 y 1.000.000
page: fc.integer({ min: 1, max: 1_000_000 }),
isActive: fc.boolean(),
});
fc.assert(
fc.property(queryArbitrary, (originalState) => {
const serialized = serializeQuery(originalState);
const parsed = parseQuery(serialized);
// Invariante: el objeto decodificado es idéntico al estado original
expect(parsed).toEqual(originalState);
}),
{ numRuns: 200 } // Evaluar 200 combinaciones aleatorias generadas
);
});
});Una transformación (como el recorte de espacios y la canonización Unicode) debe ser idempotente: procesarla sobre un resultado ya transformado debe producir exactamente el mismo valor.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
export function normalizeSearchQuery(input: string): string {
return input
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
describe('Idempotencia: normalizeSearchQuery', () => {
it('mantiene la estabilidad ante ejecuciones sucesivas f(f(x)) === f(x)', () => {
fc.assert(
fc.property(fc.fullUnicodeString(), (rawInput) => {
const firstPass = normalizeSearchQuery(rawInput);
const secondPass = normalizeSearchQuery(firstPass);
// Invariante de idempotencia
expect(secondPass).toBe(firstPass);
})
);
});
});Supongamos que un conversor monetario contiene un defecto sutil: ante cantidades negativas, introduce un guion duplicado cuando los centavos son menores que -100.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
// Función con un error de frontera intencionado
export function formatCurrencyCents(cents: number): string {
const isNegative = cents < 0;
const absCents = Math.abs(cents);
const dollars = Math.floor(absCents / 100);
const remainder = absCents % 100;
const formattedNumber = `${dollars}.${remainder.toString().padStart(2, '0')}`;
// Defecto intencionado: doble signo menos cuando cents < -100
if (cents < -100) {
return `--$${formattedNumber}`;
}
return isNegative ? `-$${formattedNumber}` : `$${formattedNumber}`;
}
describe('formatCurrencyCents (Demostración de Shrinking)', () => {
it('nunca emite más de un signo menos en la cadena resultante', () => {
const property = fc.property(fc.integer(), (cents) => {
const formatted = formatCurrencyCents(cents);
const minusCount = (formatted.match(/-/g) ?? []).length;
expect(minusCount).toBeLessThanOrEqual(1);
});
// fast-check detectará la anomalía y la reducirá al caso mínimo
expect(() => fc.assert(property)).toThrowError();
});
});Al ejecutar esta prueba, fast-check puede fallar inicialmente con -89437283. Seguidamente inicia el Shrinking:
-44718641 (falla).-1000 (falla).-101 (falla).-100 (pasa con éxito).El reporte emitido señala con precisión milimétrica:
Property failed after 14 tests
{ seed: 1892847293, path: "13:0:2:1", endOnFailure: true }
Counterexample: [-101]
Shrunk 28 time(s)El equipo de ingeniería puede identificar de inmediato la condición de rotura: exactamente el valor -101.
Reescribir la lógica de la función bajo prueba dentro de fc.property únicamente comprueba que dos implementaciones idénticas coinciden, en lugar de validar un invariante real.
// ❌ Antipatrón: el test duplica la lógica de la propia función
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(add(a, b)).toBe(a + b); // Duplicación estéril de add(a, b)
})
);
// ✅ Solución recomendada: validar propiedades matemáticas (conmutatividad, elemento neutro)
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(add(a, b)).toBe(add(b, a)); // Conmutatividad: a + b === b + a
expect(add(a, 0)).toBe(a); // Elemento neutro: a + 0 === a
})
);Emplear generadores desprovistos de restricciones de dominio provoca que la inmensa mayoría de entradas sean descartadas prematuramente por los guardianes de tipo.
// ❌ Error: pasar cualquier objeto arbitrario a un validador de correos
fc.assert(fc.property(fc.anything(), (input) => { ... }));
// ✅ Solución recomendada: generadores acotados a las reglas del dominio
const emailArbitrary = fc.emailAddress();
fc.assert(fc.property(emailArbitrary, (email) => { ... }));Si una prueba falla aleatoriamente en el servidor de CI, ejecutarla en local puede arrojar éxito debido a una semilla distinta. Para reproducirla de forma determinista se debe suministrar el seed.
// ✅ Fijación de semilla para reproducir el caso exacto de los logs de CI
fc.assert(property, {
seed: 1892847293, // Copiado directamente del reporte de fallo
});Al evaluar funciones asíncronas con el fc.property sincrónico convencional, el runner termina la ejecución antes de resolver las promesas, ignorando aserciones fallidas.
// ❌ Error: la propiedad síncrona no espera la resolución de promesas
fc.assert(
fc.property(fc.string(), async (str) => {
const res = await asyncParser(str);
expect(res).toBeDefined(); // ¡Se ejecuta cuando el test ya ha terminado!
})
);
// ✅ Solución recomendada: utilizar fc.asyncProperty
fc.assert(
fc.asyncProperty(fc.string(), async (str) => {
const res = await asyncParser(str);
expect(res).toBeDefined();
})
);Determinar la propiedad clave: Roundtrip (reversibilidad), Idempotence (estabilidad ante repetición) o Invariance (límites de magnitud o tipos).
Combinar generadores básicos (fc.string, fc.integer, fc.boolean) en modelos de dominio complejos con fc.record.
Ejecutar fc.assert(fc.property(...)) a lo largo de 100 a 500 iteraciones dentro de una suite estándar de Vitest.
Al detectar un fallo, examinar el contraejemplo mínimo reducido e incorporarlo a la suite de regresión permanente.