Le pides al agente una función para dividir la cuenta de una cena entre varias personas. La escribe en diez segundos. Le pides los tests y también los escribe: 100 céntimos entre 2, 100 entre 3, 1.000 entre 7. Todo en verde.
Incluso comprueba que la suma de las partes da el total.
Hasta que alguien reparte 3 céntimos entre 5 personas y la función le dice a una de ellas que pague -1.
Ese caso no lo eligió nadie. Los ejemplos los eligió el mismo que escribió el código y comparten sus puntos ciegos. El property-based testing en TypeScript ataca justo eso.
En corto: el property-based testing no prueba ejemplos concretos, sino reglas que deben cumplirse para cualquier entrada, como "nadie paga una cantidad negativa".
fast-check genera 100 entradas aleatorias por defecto y, si una rompe la regla, la reduce al contraejemplo más pequeño posible. Sirve para encontrar los casos límite que no eligió quien escribió el código, sea una persona o un agente de IA.
¿Qué es el property-based testing?
El property-based testing es una técnica de testing en la que describes una propiedad que la función debe cumplir para todas las entradas válidas, y la librería genera cientos de entradas aleatorias para intentar romperla.
La documentación de fast-check lo define así: una propiedad es una afirmación sobre la relación entre la entrada y la salida del código que debe cumplirse para todas las entradas.
La diferencia con un test normal es quién elige los casos: aquí los elige un generador que no conoce tu implementación y no comparte tus suposiciones.
La otra pieza es el shrinking. Cuando una entrada falla, fast-check no te devuelve un array de 400 elementos. Lo encoge hasta el caso más pequeño que sigue fallando.
El bug que pasó los tests del agente
Este es el código, tal como podría salir de cualquier asistente. Al menos trabaja en céntimos:
// split.ts
export function splitAmount(totalCents: number, people: number): number[] {
const share = Math.round(totalCents / people);
const shares = Array(people - 1).fill(share);
const last = totalCents - share * (people - 1);
return [...shares, last];
}
Y estos son sus tests:
it('reparte a partes iguales', () => {
expect(splitAmount(100, 2)).toEqual([50, 50]);
});
it('el último absorbe el redondeo', () => {
expect(splitAmount(100, 3)).toEqual([33, 33, 34]);
});
it('la suma siempre cuadra', () => {
const shares = splitAmount(1000, 7);
expect(shares.reduce((a, b) => a + b, 0)).toBe(1000);
});
Tres tests, tres verdes. La suma cuadra siempre, porque el último se queda con lo que sobre. El fallo está en que Math.round puede redondear hacia arriba, y entonces lo que "sobra" para el último es negativo.
Property-based testing en TypeScript con fast-check y Vitest
El ejemplo está ejecutado con fast-check 4.10.2, @fast-check/vitest 0.5.0, Vitest 5.0.3 y Node 24 (octubre de 2026). El paquete @fast-check/vitest añade test.prop y it.prop encima de los de Vitest, y su paquete declara como peerDependency Vitest ^4.1.0 || ^5.0.0.
npm i -D vitest fast-check @fast-check/vitest
En vez de ejemplos, escribes las reglas del negocio:
import { test, fc } from '@fast-check/vitest';
import { expect } from 'vitest';
import { splitAmount } from './split';
const total = fc.integer({ min: 0, max: 1_000_000 }); // céntimos
const people = fc.integer({ min: 1, max: 50 });
test.prop([total, people])('la suma de las partes es el total', (t, n) => {
const shares = splitAmount(t, n);
expect(shares.reduce((a, b) => a + b, 0)).toBe(t);
});
test.prop([total, people])('nadie paga una cantidad negativa', (t, n) => {
expect(Math.min(...splitAmount(t, n))).toBeGreaterThanOrEqual(0);
});
test.prop([total, people])('las partes difieren como mucho en 1 céntimo', (t, n) => {
const shares = splitAmount(t, n);
expect(Math.max(...shares) - Math.min(...shares)).toBeLessThanOrEqual(1);
});
fc.integer es un arbitrary: un generador de valores con restricciones. Hay uno para casi todo: fc.string(), fc.array(), fc.record(), fc.uuid(), fc.double().
Esta es la salida real, recortada al primer fallo:
❯ src/split.prop.test.ts (3 tests | 2 failed) 13ms
× nadie paga una cantidad negativa (with seed=1056875308) 7ms
× las partes difieren como mucho en 1 céntimo (with seed=1864299458) 1ms
FAIL src/split.prop.test.ts > nadie paga una cantidad negativa (with seed=1056875308)
Error: Property failed after 30 tests
{ seed: 1056875308, path: "29:2:4:0:0:1:0:4:3:1:1:0:5:2:1:1:0:1:3:0:2", endOnFailure: true }
Counterexample: [3,5]
Shrunk 20 time(s)
Caused by: AssertionError: expected -1 to be greater than or equal to 0
Fíjate en tres cosas. La propiedad que escribió el agente, la de la suma, pasa: era la única que se le ocurrió. El fallo apareció en el intento 30 y fast-check lo encogió 20 veces hasta [3,5]. Y splitAmount(3, 5) devuelve [1, 1, 1, 1, -1]: el bug cabe en una línea.
La segunda propiedad también cayó, encogida hasta [2,4], que da [1, 1, 1, -1].
Reproducir el fallo con seed y path
Cada ejecución usa una semilla aleatoria distinta. Para que el fallo no se pierda, pasa la seed y el path del informe como parámetros:
test.prop([total, people], {
seed: 1056875308,
path: '29:2:4:0:0:1:0:4:3:1:1:0:5:2:1:1:0:1:3:0:2',
endOnFailure: true,
})('nadie paga una cantidad negativa', (t, n) => { /* ... */ });
Según la guía para leer los informes, path arranca directamente en el contraejemplo reducido y endOnFailure para en el primer fallo sin volver a encoger. En el ejemplo, con esos parámetros falla en el primer intento con [3,5].
El fix reparte el resto céntimo a céntimo:
export function splitAmount(totalCents: number, people: number): number[] {
const base = Math.floor(totalCents / people);
const remainder = totalCents % people;
return Array.from({ length: people }, (_, i) => base + (i < remainder ? 1 : 0));
}
Las tres propiedades pasan. Y pasa algo revelador: el test de ejemplo [33, 33, 34] se rompe, porque ahora el resultado es [34, 33, 33]. Ese test no protegía una regla de negocio. Fijaba un detalle de implementación. Con numRuns: 1000 (el valor por defecto es 100, según el tipo Parameters de fast-check) las propiedades siguen en verde.
Tests de ejemplo vs. property-based testing
| Tests de ejemplo | Property-based testing | |
|---|---|---|
| Quién elige los casos | Tú o el agente, con las mismas suposiciones que el código | Un generador aleatorio con las restricciones que declares |
| Qué cubre | Los 3-5 casos escritos | 100 entradas por defecto en cada ejecución, distintas cada vez |
| Qué documenta | Un caso concreto (100 / 3 → [33, 33, 34]) |
Una regla de negocio ("nadie paga negativo") |
| Cómo depuras un fallo | Lees el caso que escribiste | Lees el contraejemplo encogido y lo reproduces con seed y path |
| Coste | Bajo de escribir y de ejecutar | Pensar la propiedad cuesta más; ejecutar, unos milisegundos más |
| Limitación o riesgo | Solo encuentra los bugs que ya imaginaste | Una propiedad débil pasa siempre y da falsa seguridad |
Seis propiedades que sirven en código real
La pregunta que bloquea a todo el mundo es "¿qué propiedad escribo?". Casi siempre cae en una de estas:
- Round-trip.
parse(serialize(x))devuelvex. ConJSON.parse(JSON.stringify(o))yfc.double(), fast-check encuentra-0,NaN,Infinityy-Infinity, que JSON convierte en0ynull. - Invariante. Algo que nunca cambia: el total cuadra, nadie paga negativo, el stock no baja de cero.
- Idempotencia. Aplicar dos veces da lo mismo que una: ordenar, normalizar un email, un
slugify. - Oráculo. Comparas tu versión optimizada con una implementación lenta pero obvia. Muy útil al refactorizar.
- Conmutatividad. El orden de entrada no cambia el resultado: aplicar dos descuentos, fusionar dos configuraciones.
- No lanza. Para cualquier entrada válida, la función devuelve algo y no explota. Es la propiedad más pobre, y aun así encuentra bugs.
Cómo usar property-based testing con agentes de IA
El cambio es pequeño y potente: no le pidas al agente "escribe tests para esta función". Pídele "escribe las propiedades que esta función debe cumplir para cualquier entrada, con fast-check". Obligarle a enunciar reglas le obliga a explicitar qué cree que hace el código, y ahí se ven las contradicciones.
Mejor aún: escribe tú las propiedades antes que el código. Una propiedad es una especificación ejecutable, y es exactamente lo que necesita un agente para saber cuándo ha terminado. Es la lógica que enseño en el workshop de SDD + Agentic Engineering: el humano define el contrato y la máquina lo cumple.
Si tus entradas ya tienen un esquema, conéctalo. Haz que el arbitrary genere objetos con fc.record y comprueba dentro de la propiedad que tu función acepta todo lo que el esquema de Zod valida.
Existe zod-fast-check para derivar arbitraries del esquema, pero su última versión en npm (0.10.1) se publicó en 2023 y exige fast-check <4 y zod ^3: hoy no compensa instalarlo.
Esto encaja con la idea de verificar el código generado por IA sin leerlo todo: no revisas línea a línea, revisas que las reglas se cumplan.
Y complementa lo que contamos sobre testing de agentes con TDD y sobre validar código antes de hacer merge.
Cuándo NO usar property-based testing
Cuando no encuentras una propiedad que no sea la propia función. Si para comprobar el resultado tienes que reimplementar el algoritmo, estás comparando el bug consigo mismo. En código de pegamento (llamar a una API, mapear un DTO a otro) a menudo no hay propiedad interesante.
En UI y efectos secundarios. Generar cientos de entradas contra una base de datos, una API de pago o un componente renderizado es lento y deja estado detrás. Existe testing basado en modelos para esto, pero el coste de montarlo sube mucho.
Cuando dejas el azar suelto en CI. Cada ejecución usa otra semilla. Un fallo que aparece una vez y no vuelve es real, pero si no guardas la seed y el path lo pierdes. Fija la semilla al reproducir y convierte el contraejemplo en un test de ejemplo permanente.
Cuando confías ciegamente en el shrinking. Es heurístico. Con las dos aserciones del ejemplo juntas en una sola propiedad, fast-check se quedó en [68630,4] (con otra semilla puede salir distinto) en vez de bajar a [2,4]. Una propiedad por regla da contraejemplos más limpios.
La barrera de entrada también es real. En el hilo de Hacker News The sad state of property-based testing libraries, un usuario defiende que el shrinking es, cito, "90% of the value of PBT".
Otro apunta que el problema de formación "is a very real and fundamental blocker" para adoptarlo en equipos.
Empieza por una función de dinero
Elige hoy la función de tu proyecto que toca importes, fechas o parseo de texto. Escribe una sola propiedad: la invariante que, si se rompe, le cuesta dinero a alguien. Ejecútala con test.prop.
Si pasa, súbele numRuns. Si falla, acabas de encontrar el caso que nadie pensó, con su contraejemplo mínimo y su semilla para reproducirlo.
Y si estás construyendo producto con agentes, el curso Construye con IA recorre cómo poner estas redes de seguridad desde la idea hasta producción.
Para el contexto de por qué validar las entradas en los bordes, repasa también la programación defensiva en TypeScript.
Preguntas frecuentes
¿Qué diferencia hay entre property-based testing y fuzzing?
Los dos generan entradas automáticamente. El fuzzing suele buscar cuelgues y excepciones, a menudo guiado por cobertura. El property-based testing comprueba reglas de negocio que tú declaras y reduce el fallo a un contraejemplo mínimo.
¿Cuántos casos genera fast-check por defecto?
100 por propiedad y por ejecución, según la documentación del parámetro numRuns de fast-check 4.10.2. Puedes subirlo con { numRuns: 1000 } en las propiedades críticas, a costa de algo más de tiempo.
¿Sirve fast-check con Jest o solo con Vitest?
fast-check funciona igual en JavaScript y en TypeScript, y con cualquier runner: fc.assert(fc.property(...)) lanza una excepción si la propiedad falla, y Jest o Mocha la tratan como un test fallido. @fast-check/vitest solo añade la sintaxis test.prop y muestra la semilla en el nombre del test.
¿Pueden los agentes de IA escribir buenas propiedades?
Sí, si se lo pides explícitamente y revisas lo que proponen. El riesgo es que escriban propiedades débiles, como "devuelve un array", que pasan siempre. Revisar unas pocas reglas es más rápido que revisar decenas de ejemplos, y una regla débil se detecta leyéndola.
¿Los tests de propiedades sustituyen a los tests de ejemplo?
No. Los ejemplos documentan casos concretos que un humano lee para entender la función. Las propiedades exploran el espacio de entradas. Cuando una propiedad encuentra un bug, guarda el contraejemplo como test de ejemplo para que no vuelva.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

Leave a Reply