Semana 10 — Pruebas basadas en propiedades, fuzzing, mutación y oráculos
Objetivos
- Escribir propiedades (invariantes, ida y vuelta, comparación con un modelo, relaciones metamórficas) a partir de una especificación, y generadores que lleguen a los casos interesantes.
- Explicar qué hace un runner de propiedades: generar entradas, encontrar un contraejemplo, reducirlo (shrinking) y reproducirlo con una semilla.
- Usar fuzzing con un oráculo implícito (“el servidor nunca responde 500”) para encontrar entradas que nadie pensó.
- Medir con pruebas de mutación cuánto verifica una suite, leer los mutantes que sobreviven y distinguir un hueco en las pruebas de un mutante equivalente.
- Explicar el problema del oráculo y por qué las pruebas que escribe la misma herramienta que escribió el código no son un oráculo independiente.
Sesión A — Pruebas basadas en propiedades y fuzzing
Lecturas:
- fast-check, Introduction y Arbitraries.
- David R. MacIver, What is Property Based Testing? (Hypothesis).
- Koen Claessen y John Hughes, QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs (ICFP 2000), la introducción: el artículo que inició la técnica. El código es Haskell; lean las ideas.
Lecturas complementarias:
- fast-check, Model-based testing.
- Andreas Zeller y otros, The Fuzzing Book, Fuzzing: Breaking Things with Random Inputs.
- Software Engineering at Google (SWEG), cap. 14, Larger Testing: fidelidad de las pruebas y pruebas de regresión por diferencias (A/B diff).
Temas:
- Una prueba de ejemplo dice “para esta entrada, este resultado”. Una propiedad dice “para toda entrada de este dominio, se cumple esto”. El runner (fast-check, como QuickCheck en Haskell e Hypothesis en Python) genera entradas al azar con arbitraries (
fc.integer({ min: 7, max: 21 }),fc.constantFrom(...SPORTS),fc.record(...)), corre la propiedad 100 veces por omisión y se detiene en el primer contraejemplo. - Shrinking: cuando encuentra un contraejemplo, el runner prueba versiones más simples (números más chicos, listas más cortas, la primera opción de cada
constantFrom) mientras sigan fallando, y muestra la más simple. Junto con el contraejemplo imprime la semilla (seed) y el camino (path); pasándolos afc.assertse repite exactamente la misma corrida. - Clases de propiedades: invariantes (un precio nunca es negativo ni tiene fracciones de céntimo); ida y vuelta (
daysBetween(d, addDays(d, n)) === n); comparación con un modelo simple y obviamente correcto (dos bloques se cruzan si comparten alguna hora, contado hora por hora); relaciones metamórficas, que dicen cómo cambia la salida cuando cambia la entrada (el precio de un socio es el 90 % del de un regular en la misma cancha y horario); equivalencia con otra implementación (la versión anterior a un refactoring, como oráculo). - Generadores que llegan a los casos interesantes: construir entradas válidas en vez de filtrarlas (
fc.integer({ min: 0, max: 365 }).map((d) => addDays('2026-01-01', d))da fechas válidas; generar texto y descartar lo que no es fecha casi nunca acierta).fc.pre(...)descarta casos que no cumplen una precondición; si descarta la mayoría, fast-check avisa. - Error común: una propiedad que repite la implementación con otras palabras (
isPeakHour(d, h) === (isWeekend(d) || h >= PEAK_START_HOUR)). Pasa siempre, incluido cuando la implementación está mal. La propiedad sale de la especificación (el README de Kallpa), no del código. - Otro error común: un generador que nunca produce el caso que importa. Si
hourssiempre es 1 o las fechas siempre son de enero, la propiedad pasa sin haber mirado la mitad del dominio.fc.statisticsmuestra qué se generó. - Fuzzing: alimentar un programa con entradas aleatorias o malformadas en cantidad, buscando fallas. El oráculo es implícito: el programa no debe caerse, colgarse ni responder 500. Los fuzzers guiados por cobertura (AFL, libFuzzer) mutan entradas que llegaron a código nuevo; un fuzzer basado en generadores, como una propiedad de fast-check sobre las rutas de una API, es más simple y sirve igual para empezar.
- Con IA: las propiedades son una buena especificación para un agente porque no se pueden hacer pasar con casos especiales (
if (hours === 2) ...), como sí se puede con unos pocos ejemplos. Y al revés: una propiedad generada por una herramienta hay que leerla como cualquier prueba, porque puede ser una tautología.
Sesión B — Pruebas de mutación y oráculos para código generado
Lecturas:
- StrykerJS, Introduction y Tap Runner.
- Goran Petrović y Marko Ivanković, State of Mutation Testing at Google (ICSE SEIP 2018).
Lecturas complementarias:
- StrykerJS, Supported mutators y Configuration.
- Carnegie Mellon, 17-313 Foundations of Software Engineering: sus clases de pruebas y de calidad.
Temas:
- La pregunta que la cobertura no responde: si el código tuviera un error aquí, ¿alguna prueba fallaría? Las pruebas de mutación lo miden. Un mutante es una copia del programa con un cambio pequeño:
>=por>,+por-, una condición reemplazada portrue, un bloque vaciado, un texto cambiado. Se corre la suite contra cada mutante: si alguna prueba falla, el mutante muere; si todas pasan, sobrevive. - Resultados de Stryker: killed, survived, no coverage (ninguna prueba ejecuta esa línea), timeout (el mutante se colgó, cuenta como detectado). El mutation score es la proporción de mutantes detectados sobre los válidos. Un mutante que sobrevive señala un comportamiento que ninguna prueba fija.
- Por qué funciona: la hipótesis del programador competente (los errores reales suelen estar a un cambio pequeño del código correcto) y el efecto de acoplamiento (las pruebas que detectan errores simples suelen detectar también los compuestos).
- Mutantes equivalentes: un cambio que no altera el comportamiento observable (cambiar
<por!=en unforque solo avanza de uno en uno). No se pueden matar y no indican un hueco: se marcan y se sigue. - Costo y práctica: hay que correr la suite muchas veces. Stryker solo corre, para cada mutante, las pruebas que pasan por esa línea; el runner
tapque usamos corre cada archivo de prueba denode:testcomo un proceso. En Google, la mutación se muestra en la revisión de código, solo sobre las líneas que cambió el pull request y descartando los mutantes que no aportan (por ejemplo, en logs); así deja de ser un número para la gerencia y se vuelve un comentario concreto en el pull request. - El problema del oráculo: para saber si una prueba pasa, alguien tiene que saber cuál es la salida correcta. Fuentes de oráculo: los ejemplos de la especificación, propiedades, un modelo, otra implementación o la versión anterior (diferencial), relaciones metamórficas, y el oráculo implícito del fuzzing.
- Oráculos para código generado: si un agente escribe el código y sus pruebas, las pruebas comparten los malentendidos del código y no son un oráculo independiente. Lo que sí es independiente: la especificación escrita antes (los criterios de aceptación, el README de Kallpa), las propiedades que escribe el equipo, la versión anterior del código, y las pruebas de mutación para medir si las pruebas del agente verifican algo.
- Error común: perseguir 100 % de mutation score o leerlo como “el código es correcto”. Un score alto dice que las pruebas notan los cambios en el código probado; no dice nada de un requisito que nadie entendió bien ni de una regla que el código no implementa.
Laboratorio — Encontrar con fast-check y Stryker errores que las pruebas no ven
Referencias: fast-check, StrykerJS Tap Runner, las reglas del negocio del README de Kallpa. Material: code/week-10/ (la configuración de Stryker y tres cambios escritos para la clase, como los que suele proponer un agente de IA). En parejas.
- Preparar una copia limpia de Kallpa tal como se publicó, para que los cambios del paso 6 apliquen sin conflictos con lo que ya hicieron en su repositorio:
cp -r /work/kallpa /work/kallpa-semana10 && cd /work/kallpa-semana10 && git init -q && git add -A && git commit -qm "Kallpa publicada" && git tag base, y copiar/work/code/week-10/stryker.config.jsona esa carpeta. Corrermake test. - Escribir en
test/pricing.property.test.tscomo propiedades de fast-check dos reglas de precios del README: “cada hora se cobra con la tarifa que le corresponde” (el precio de una reserva es la suma de los precios de sus horas) y “los socios tienen 10 % de descuento” (relación metamórfica entreplan: 'socio'yplan: 'regular'). Generar deporte, plan, fechas válidas de todo un año, hora de inicio y horas, confc.pre(startHour + hours <= 22). Corrernode --test test/pricing.property.test.ts. Si falla: leer el contraejemplo ya reducido, comprobarlo a mano con la tabla del README, agregarlo como prueba de ejemplo, corregirsrc/domain/pricing.tsy volver a correr todo. - Una propiedad de ida y vuelta para el calendario en
test/calendar.property.test.ts: para una fecha válidad(generada conaddDays) y un enteronentre −400 y 400,daysBetween(d, addDays(d, n)) === n. - Fuzzing de la API en
test/api.fuzz.test.ts: constartTestServer()detest/helpers.tsyfc.asyncProperty(fc.string(), ...), pedir/api/courts/<texto>/availability?date=2026-03-10con 300 textos al azar ({ numRuns: 300 }) y afirmar solo que la respuesta no es 500. Si encuentran un contraejemplo, reproducirlo concurlcontramake start, buscar en el log del servidor dónde se origina, y corregirlo con una respuesta de error de la tabla del README (justifiquen cuál). - Pruebas de mutación:
stryker runcon la configuración copiada, que mutasrc/domain/pricing.tsysrc/domain/booking.tsy corre los archivos de prueba de la listatap.testFiles. Agreguen a esa lista sus archivos nuevos de los pasos 2 y 3 (el de fuzzing no: haría cientos de solicitudes HTTP por cada mutante). Stryker solo arranca si todas las pruebas pasan. Anotar el mutation score y los mutantes que sobreviven. Para todos los depricing.tsy al menos tres debooking.ts(uno de ellos enisCancellableAt), escribir la prueba que lo mata, con el valor esperado sacado del README y no del código, o explicar por qué es equivalente. Volver a correr Stryker y anotar el nuevo score. El reporte completo queda enreports/mutation/mutation.html. - Tres cambios como los que propone un agente: cada archivo
code/week-10/agente-*.difftrae al inicio la tarea y la descripción que los acompaña; son ilustraciones escritas para la clase. Antes, hagan commit enmainde todo lo de los pasos 2 a 5. Para cada diff, en una rama nueva desdemain(git switch -c agente-1 main,git apply /work/code/week-10/agente-1-cruces.diff): leer el diff como en una revisión de código; anotar que con las pruebas de Kallpa tal como se publicó el cambio pasa; corrermake testcon las suyas, ystryker runcuando todas pasen. Al terminar con cada rama, hagan commit en ella (ogit checkout -- .) antes de crear la siguiente. Cada cambio tiene al menos un error. Si sus pruebas no lo encuentran, escriban un oráculo nuevo a partir del README (un modelo, una propiedad o un caso límite) hasta que falle, y expliquen por qué las pruebas anteriores no lo veían. - Llevar a su repositorio de Kallpa, en un pull request, las propiedades, el fuzzing, las pruebas que matan mutantes y la configuración de Stryker. En la descripción, una tabla: cada error encontrado (de Kallpa o de los cambios del paso 6), qué técnica lo encontró (propiedad, fuzzing, mutación o lectura del diff) y qué prueba lo evita ahora. Anotar qué habrían aceptado de esos cambios sin estas pruebas.
Entregas
Sin entregas esta semana. P3 se entrega la semana 11 y pide pruebas de propiedades sobre sus reglas de negocio (projects/p3-calidad/README.md).
Preguntas de repaso
- Escriban tres propiedades de clases distintas (invariante, ida y vuelta, metamórfica) para una regla de negocio del producto de su cliente. ¿De qué parte de la especificación sale cada una?
- Una propiedad sobre una función de descuentos falla con
["socio", 7, 38]y, después del shrinking, fast-check reporta["regular", 0, 10]. ¿Qué hizo el runner para llegar ahí, por qué el segundo contraejemplo es más útil que el primero, y cómo repetirían exactamente esa corrida? - ¿Por qué la propiedad
isPeakHour(d, h) === (isWeekend(d) || h >= PEAK_START_HOUR)no sirve como oráculo paraisPeakHour? Escriban una que sí sirva, sacada del README de Kallpa. - En el producto de su equipo, un mutante cambia
stock >= quantityporstock > quantityen la función que acepta un pedido, y sobrevive. ¿Qué prueba falta? ¿Cómo sabrían si fuera un mutante equivalente? - Un agente les entrega una función con diez pruebas, todas en verde, y Stryker da 55 % de mutation score sobre esa función. ¿Qué concluyen de las pruebas, qué concluyen del código, y qué hacen antes de aceptar el cambio?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (19)Pruebas (22)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
Ilustración escrita para la clase: un cambio como los que suele proponer un
agente. No es la respuesta de ninguna herramienta.
Tarea: "Simplifica la función overlaps de src/domain/booking.ts; es difícil de leer."
Descripción que acompaña el cambio: "Reescribí overlaps en una sola comparación más fácil de leer: dos bloques se cruzan si sus inicios están más cerca que la duración del más largo. Corrí make test y las 40 pruebas pasan."
diff --git a/src/domain/booking.ts b/src/domain/booking.ts
index d58bac2..ff1b3c9 100644
--- a/src/domain/booking.ts
+++ b/src/domain/booking.ts
@@ -74,9 +74,9 @@ export function checkBookingRules(slot: TimeSlot, now: LimaTime): BookingRuleVio
return null;
}
-// Dos bloques se cruzan si cada uno empieza antes de que el otro termine.
+// Dos bloques se cruzan si sus inicios están más cerca que la duración del más largo.
export function overlaps(a: Pick<TimeSlot, 'startHour' | 'hours'>, b: Pick<TimeSlot, 'startHour' | 'hours'>): boolean {
- return a.startHour < b.startHour + b.hours && b.startHour < a.startHour + a.hours;
+ return Math.abs(a.startHour - b.startHour) < Math.max(a.hours, b.hours);
}
export function minutesUntilStart(slot: Pick<TimeSlot, 'date' | 'startHour'>, now: LimaTime): number {Ilustración escrita para la clase: un cambio como los que suele proponer un
agente. No es la respuesta de ninguna herramienta.
Tarea: "Haz más legible isPeakHour en src/domain/pricing.ts y deja explícito hasta cuándo dura la hora punta."
Descripción que acompaña el cambio: "Dejé explícito que la hora punta va desde PEAK_START_HOUR hasta el cierre, usando la constante CLOSING_HOUR de booking.ts en lugar de dejarlo implícito. Corrí make test y las 40 pruebas pasan."
diff --git a/src/domain/pricing.ts b/src/domain/pricing.ts
index c1cbf69..72d3fd6 100644
--- a/src/domain/pricing.ts
+++ b/src/domain/pricing.ts
@@ -1,3 +1,4 @@
+import { CLOSING_HOUR } from './booking.ts';
import { isWeekend } from './calendar.ts';
import type { Sport } from './court.ts';
import type { MemberPlan } from './member.ts';
@@ -19,7 +20,8 @@ export const PEAK_SURCHARGE_PERCENT = 25;
export const SOCIO_DISCOUNT_PERCENT = 10;
export function isPeakHour(date: string, hour: number): boolean {
- return isWeekend(date) || hour >= PEAK_START_HOUR;
+ // La hora punta va de PEAK_START_HOUR hasta el cierre.
+ return isWeekend(date) || (hour >= PEAK_START_HOUR && hour < CLOSING_HOUR - 1);
}
export function hourlyRateCents(sport: Sport, date: string, hour: number): number {Ilustración escrita para la clase: un cambio como los que suele proponer un
agente. No es la respuesta de ninguna herramienta.
Tarea: "Haz más clara la regla de cancelación en src/domain/booking.ts: que se lea el límite de 3 horas."
Descripción que acompaña el cambio: "Extraje el límite a una constante local con nombre para que la condición se lea mejor. No cambia el comportamiento. Corrí make test y las 40 pruebas pasan."
diff --git a/src/domain/booking.ts b/src/domain/booking.ts
index d58bac2..b20b74d 100644
--- a/src/domain/booking.ts
+++ b/src/domain/booking.ts
@@ -86,5 +86,6 @@ export function minutesUntilStart(slot: Pick<TimeSlot, 'date' | 'startHour'>, no
// Una reserva se puede cancelar hasta CANCELLATION_NOTICE_HOURS horas antes
// de que empiece.
export function isCancellableAt(slot: Pick<TimeSlot, 'date' | 'startHour'>, now: LimaTime): boolean {
- return minutesUntilStart(slot, now) >= CANCELLATION_NOTICE_HOURS * 60;
+ const limit = CANCELLATION_NOTICE_HOURS * 60;
+ return minutesUntilStart(slot, now) > limit;
}// El runner de Node avisa a sus procesos hijos con NODE_TEST_CONTEXT que reporten
// en su formato interno. Un `node --test` o un Stryker lanzado desde una prueba
// tiene que correr como si se lanzara desde la terminal.
export const CLEAN_ENV: NodeJS.ProcessEnv = { ...process.env };
delete CLEAN_ENV.NODE_TEST_CONTEXT;// Un refactoring de formatSoles: sin pasar por números decimales.
export function formatSolesNoFloat(cents: number): string {
const soles = Math.trunc(cents / 100);
const rest = String(cents % 100).padStart(2, '0');
return `S/ ${soles}.${rest}`;
}// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig(
{ ignores: ['build/', 'solution/', '.stryker-tmp/', 'reports/'] },
js.configs.recommended,
tseslint.configs.recommended,
);// Fuzzing de POST /api/bookings con dos generadores: make fuzz.
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { addDays } from '../../kallpa/src/domain/calendar.ts';
import { postJson, startTestServer } from '../../kallpa/test/helpers.ts';
const anyValue = fc.oneof(fc.integer(), fc.double(), fc.string(), fc.constant(null), fc.boolean(), fc.array(fc.integer()));
// Un cuerpo con los nombres correctos y valores de cualquier tipo.
export const looseBody = fc.record(
{ courtId: anyValue, memberId: anyValue, date: anyValue, startHour: anyValue, hours: anyValue, teamName: anyValue },
{ requiredKeys: [] },
);
// Un cuerpo con los tipos correctos y valores cerca y lejos de los bordes. Las
// fechas salen de addDays: alrededor del lunes 9 de marzo de 2026, el reloj de
// las pruebas.
export const shapedBody = fc.record({
courtId: fc.integer({ min: 0, max: 5 }),
memberId: fc.integer({ min: 0, max: 11 }),
date: fc.integer({ min: -2, max: 16 }).map((d) => addDays('2026-03-09', d)),
startHour: fc.integer({ min: 5, max: 23 }),
hours: fc.integer({ min: 0, max: 5 }),
teamName: fc.string({ maxLength: 50 }),
});
export async function fuzzBookings(body: fc.Arbitrary<object>, numRuns: number): Promise<Record<string, number>> {
const server = await startTestServer();
const counts: Record<string, number> = {};
try {
await fc.assert(
fc.asyncProperty(body, async (payload) => {
const { status, body: answer } = await postJson(`${server.url}/api/bookings`, payload);
const key = `${status} ${answer.error?.code ?? 'reserva creada'}`;
counts[key] = (counts[key] ?? 0) + 1;
assert.notEqual(status, 500);
}),
{ numRuns, seed: 2026 },
);
} finally {
await server.close();
}
return counts;
}
if (import.meta.main) {
console.log('Valores de cualquier tipo:', await fuzzBookings(looseBody, 1000));
console.log('Tipos correctos, valores cerca de los bordes:', await fuzzBookings(shapedBody, 1000));
}// Mide qué producen los generadores: make generadores.
import fc from 'fast-check';
import { addDays, dayOfWeek, isValidDate } from '../../kallpa/src/domain/calendar.ts';
const SEED = 2026;
export const weakDate = fc.integer({ min: 0, max: 30 }).map((d) => addDays('2026-01-05', d));
export const goodDate = fc.integer({ min: 0, max: 364 }).map((d) => addDays('2026-01-01', d));
const hour = fc.integer({ min: 7, max: 21 });
const hours = fc.integer({ min: 1, max: 3 });
const dayKind = (date: string) => (dayOfWeek(date) === 0 || dayOfWeek(date) === 6 ? 'fin de semana' : 'lunes a viernes');
const month = (date: string) => date.slice(0, 7);
// Cuántos textos al azar tienen la forma de una fecha.
export function validDatesAmongStrings(samples: number): number {
return fc.sample(fc.string(), { numRuns: samples, seed: SEED }).filter(isValidDate).length;
}
// Cuántos pares válidos (inicio + horas <= 22) y con cuántas horas.
export function hoursDistribution(samples: number): Record<number, number> {
const counts: Record<number, number> = {};
for (const [start, n] of fc.sample(fc.tuple(hour, hours), { numRuns: samples, seed: SEED })) {
if (start + n <= 22) counts[n] = (counts[n] ?? 0) + 1;
}
return counts;
}
if (import.meta.main) {
console.log(`Textos con forma de fecha entre 100000 de fc.string(): ${validDatesAmongStrings(100_000)}`);
console.log('\nFechas de un mes: tipo de día');
fc.statistics(weakDate, dayKind, { numRuns: 1000, seed: SEED });
console.log('\nFechas de un mes: mes');
fc.statistics(weakDate, month, { numRuns: 1000, seed: SEED });
console.log('\nFechas de todo el año: tipo de día');
fc.statistics(goodDate, dayKind, { numRuns: 1000, seed: SEED });
console.log('\nHoras de la reserva (con inicio + horas <= 22):', hoursDistribution(1000));
}// El modelo de los cruces: dos bloques se cruzan si comparten alguna hora,
// contado hora por hora. Es lento y obviamente correcto.
import fc from 'fast-check';
export type Block = { startHour: number; hours: number };
export const blockArb = fc.record({
startHour: fc.integer({ min: 7, max: 21 }),
hours: fc.integer({ min: 1, max: 3 }),
});
export function hoursOf(block: Block): number[] {
return Array.from({ length: block.hours }, (_, i) => block.startHour + i);
}
export function sharesAnHour(a: Block, b: Block): boolean {
const own = hoursOf(a);
return hoursOf(b).some((hour) => own.includes(hour));
}// La liga de vóley manda sus pedidos como texto: "18-20" es de 18:00 a 20:00.
export class RangeFormatError extends Error {}
export type HourRange = { startHour: number; hours: number };
// La primera versión. Funciona con todo lo que la liga ha mandado hasta ahora.
export function parseHourRangeNaive(text: string): HourRange {
const [start, end] = text.split('-');
return { startHour: Number(start), hours: Number(end.trim()) - Number(start) };
}
// Toda entrada rara termina en un error que el programa sabe manejar.
export function parseHourRange(text: string): HourRange {
const match = /^\s*(\d{1,2})\s*-\s*(\d{1,2})\s*$/.exec(text);
if (match === null) throw new RangeFormatError(`No entiendo el rango: ${text}`);
const startHour = Number(match[1]);
return { startHour, hours: Number(match[2]) - startHour };
}// Dividir el pago de una reserva entre los jugadores. El dinero va en céntimos,
// siempre enteros, y lo que pagan todos tiene que sumar exactamente el total.
// La versión que se le ocurre a cualquiera: todos pagan el promedio redondeado.
// Pasa los ejemplos fáciles.
export function splitCentsNaive(totalCents: number, players: number): number[] {
return Array.from({ length: players }, () => Math.round(totalCents / players));
}
// La versión correcta: todos pagan el piso del promedio y los céntimos que
// sobran se reparten de a uno.
export function splitCents(totalCents: number, players: number): number[] {
const base = Math.floor(totalCents / players);
const remainder = totalCents - base * players;
return Array.from({ length: players }, (_, i) => (i < remainder ? base + 1 : base));
}
// Una función hecha a la medida de los ejemplos: da lo correcto en cada caso de
// las pruebas y no hace nada útil en los demás.
export function splitCentsFake(totalCents: number, players: number): number[] {
if (totalCents === 9000 && players === 3) return [3000, 3000, 3000];
if (totalCents === 11250 && players === 2) return [5625, 5625];
if (totalCents === 100 && players === 3) return [34, 33, 33];
return [totalCents];
}{
"testRunner": "tap",
"tap": {
"testFiles": ["test/pricing.test.ts", "test/booking.test.ts"],
"nodeArgs": ["--test-reporter=tap"]
},
"mutate": ["src/domain/pricing.ts", "src/domain/booking.ts"],
"reporters": ["clear-text", "progress", "html"],
"plugins": ["@stryker-mutator/tap-runner"]
}// Ilustración escrita para la clase: un cambio como los que suele proponer un
// agente cuando se le pide "valida la anticipación máxima de 14 días". El error
// de lectura está en el código y en sus pruebas (booking-window.test.ts).
export const WINDOW_DAYS = 14;
export function isWithinBookingWindow(daysAhead: number): boolean {
return daysAhead >= 0 && daysAhead < WINDOW_DAYS;
}{
"testRunner": "tap",
"tap": { "testFiles": ["voucher-marked.test.ts"], "nodeArgs": ["--test-reporter=tap"] },
"mutate": ["voucher-marked.ts"],
"reporters": ["clear-text", "json"],
"jsonReporter": { "fileName": "reports/marked.json" },
"plugins": ["@stryker-mutator/tap-runner"]
}{
"testRunner": "tap",
"tap": { "testFiles": ["voucher.test.ts"], "nodeArgs": ["--test-reporter=tap"] },
"mutate": ["voucher.ts"],
"reporters": ["clear-text", "json"],
"jsonReporter": { "fileName": "reports/strong.json" },
"plugins": ["@stryker-mutator/tap-runner"]
}{
"testRunner": "tap",
"tap": { "testFiles": ["voucher.weak.test.ts"], "nodeArgs": ["--test-reporter=tap"] },
"mutate": ["voucher.ts"],
"reporters": ["clear-text", "json"],
"jsonReporter": { "fileName": "reports/weak.json" },
"plugins": ["@stryker-mutator/tap-runner"]
}{
"testRunner": "tap",
"tap": { "testFiles": ["booking-window.test.ts"], "nodeArgs": ["--test-reporter=tap"] },
"mutate": ["booking-window.ts"],
"reporters": ["clear-text", "json"],
"jsonReporter": { "fileName": "reports/window.json" },
"plugins": ["@stryker-mutator/tap-runner"]
}// voucher.ts con el mutante equivalente marcado: el reporte lo cuenta aparte.
export function amountDueCents(totalCents: number, voucherCents: number): number {
// Stryker disable next-line EqualityOperator: con voucher === total, el resto ya da 0.
if (voucherCents >= totalCents) return 0;
return totalCents - voucherCents;
}// Un vale de descuento de la junta vecinal: se descuenta del total de la
// reserva, y nunca se paga menos de cero.
export function amountDueCents(totalCents: number, voucherCents: number): number {
if (voucherCents >= totalCents) return 0;
return totalCents - voucherCents;
}
// Varios vales sobre la misma reserva se aplican en orden.
export function applyVouchers(totalCents: number, vouchers: readonly number[]): number {
let due = totalCents;
for (let i = 0; i < vouchers.length; i++) {
due = amountDueCents(due, vouchers[i]);
}
return due;
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// Las pruebas de demos/ fallan a propósito. Aquí se comprueba que fallan y por
// la razón que dice el capítulo.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { describe, test } from 'node:test';
import { CLEAN_ENV } from './clean-env.ts';
function runDemo(file: string): { status: number | null; output: string } {
const { status, stdout, stderr } = spawnSync('node', ['--test', `demos/${file}.demo.test.ts`], { encoding: 'utf8', env: CLEAN_ENV });
return { status, output: stdout + stderr };
}
const expected: Record<string, string[]> = {
'split-naive': ['Counterexample: [1564,3]', '1563 !== 1564', 'seed: 2026'],
'split-naive-verbose': ['[99991,3]', 'Encountered failures were'],
'split-naive-replay': ['Counterexample: [1564,3]', 'Shrunk 0 time(s)'],
'split-fake': ['Counterexample: [0,2]', '1 !== 2'],
'overlaps-model': ['Counterexample: [{"startHour":19,"hours":1},{"startHour":18,"hours":2}]', 'false !== true'],
'too-many-discards': ['too many pre-condition failures', 'Ran 0 time(s)'],
differential: ['Counterexample: [-1]', "S/ 0.-1", "S/ -0.01"],
'parse-range': ['Counterexample: [""]', "Cannot read properties of undefined (reading 'trim')"],
'window-readme': ['false !== true'],
};
describe('las demostraciones fallan como dice el capítulo', () => {
for (const [file, fragments] of Object.entries(expected)) {
test(file, () => {
const { status, output } = runDemo(file);
assert.notEqual(status, 0);
for (const fragment of fragments) assert.ok(output.includes(fragment), `falta "${fragment}" en:\n${output}`);
});
}
test('en overlaps-model, los dos ejemplos de Kallpa pasan y solo falla el modelo', () => {
const { output } = runDemo('overlaps-model');
assert.match(output, /✔ los dos ejemplos de test\/booking.test.ts pasan/);
assert.match(output, /✖ coincide con el modelo/);
});
});import assert from 'node:assert/strict';
import { test } from 'node:test';
import { fuzzBookings, looseBody, shapedBody } from './fuzz-bookings.ts';
test('con valores de cualquier tipo casi todo se queda en la validación, sin 500', async () => {
const counts = await fuzzBookings(looseBody, 300);
assert.ok((counts['400 VALIDATION_ERROR'] ?? 0) >= 295, JSON.stringify(counts));
});
test('con los tipos correctos llega a las reglas del negocio y a crear reservas, sin 500', async () => {
const counts = await fuzzBookings(shapedBody, 1000);
assert.ok(Object.keys(counts).length >= 6, JSON.stringify(counts));
assert.ok((counts['200 reserva creada'] ?? 0) > 0, JSON.stringify(counts));
});import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { goodDate, hoursDistribution, validDatesAmongStrings, weakDate } from './generators.ts';
import { isValidDate } from '../../kallpa/src/domain/calendar.ts';
test('fc.string() casi nunca da algo con forma de fecha', () => {
assert.equal(validDatesAmongStrings(100_000), 0);
});
test('los generadores construidos siempre dan fechas con la forma correcta', () => {
assert.ok(fc.sample(goodDate, { numRuns: 1000 }).every(isValidDate));
});
test('el generador de un mes no pasa de febrero', () => {
assert.ok(fc.sample(weakDate, { numRuns: 1000 }).every((d) => d < '2026-03-01'));
});
test('el generador del año pasa por los doce meses', () => {
const months = new Set(fc.sample(goodDate, { numRuns: 5000, seed: 2026 }).map((d) => d.slice(5, 7)));
assert.equal(months.size, 12);
});
test('las tres duraciones aparecen', () => {
const counts = hoursDistribution(1000);
assert.ok(counts[1] > 0 && counts[2] > 0 && counts[3] > 0);
});// Lo que el laboratorio da por hecho sobre su material: los tres diffs aplican a
// la Kallpa publicada, y con ellos sus 40 pruebas siguen pasando. Stryker corre
// con la configuración de la semana.
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { after, before, describe, test } from 'node:test';
import { CLEAN_ENV } from './clean-env.ts';
const DIFFS = ['agente-1-cruces.diff', 'agente-2-hora-punta.diff', 'agente-3-cancelacion.diff'];
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', ['-c', 'user.name=Kallpa', '-c', 'user.email=kallpa@example.com', ...args], { cwd, encoding: 'utf8' });
describe('material del laboratorio', () => {
let dir: string;
before(() => {
mkdirSync('build', { recursive: true });
dir = resolve(mkdtempSync('build/lab-'));
cpSync('../../kallpa', dir, { recursive: true });
git(dir, 'init', '-q');
git(dir, 'add', '-A');
git(dir, 'commit', '-qm', 'Kallpa publicada');
});
after(() => rmSync(dir, { recursive: true, force: true }));
for (const diff of DIFFS) {
test(`${diff} se presenta como ilustración, aplica y pasan las 40 pruebas publicadas`, () => {
const path = resolve(diff);
const header = readFileSync(path, 'utf8').split('diff --git')[0];
assert.match(header, /Ilustración escrita para la clase/);
assert.match(header, /Tarea: /);
assert.match(header, /Descripción que acompaña el cambio: /);
git(dir, 'apply', path);
try {
const { status, stdout } = spawnSync('node', ['--test', 'test/**/*.test.ts'], { cwd: dir, encoding: 'utf8', env: CLEAN_ENV });
assert.equal(status, 0, stdout);
assert.match(stdout, /ℹ pass 40/);
} finally {
git(dir, 'checkout', '-q', '--', '.');
}
});
}
test('stryker.config.json corre sobre la Kallpa publicada y deja su reporte', () => {
cpSync('stryker.config.json', join(dir, 'stryker.config.json'));
execFileSync('stryker', ['run'], { cwd: dir, stdio: 'ignore', env: CLEAN_ENV });
assert.match(readFileSync(join(dir, 'reports/mutation/mutation.html'), 'utf8'), /<html/);
assert.doesNotMatch(git(dir, 'status', '--porcelain'), /reports|stryker-tmp/);
});
});// Corre Stryker sobre los ejemplos de mutation/, en una copia dentro de build/,
// y comprueba los números y los mutantes que cita el capítulo.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { after, before, describe, test } from 'node:test';
import { CLEAN_ENV } from './clean-env.ts';
type Mutant = { status: string; mutatorName: string; replacement: string; location: { start: { line: number } } };
let dir: string;
function stryker(name: string): Mutant[] {
execFileSync('stryker', ['run', `stryker.${name}.json`], { cwd: dir, stdio: 'ignore', env: CLEAN_ENV });
const report = JSON.parse(readFileSync(join(dir, `reports/${name}.json`), 'utf8'));
return Object.values<{ mutants: Mutant[] }>(report.files).flatMap((file) => file.mutants);
}
const count = (mutants: Mutant[], status: string) => mutants.filter((m) => m.status === status).length;
describe('pruebas de mutación de los ejemplos', () => {
before(() => {
mkdirSync('build', { recursive: true });
dir = mkdtempSync('build/mutation-');
cpSync('mutation', dir, { recursive: true });
});
after(() => rmSync(dir, { recursive: true, force: true }));
test('la prueba débil cubre todo y solo detecta 3 de 12 mutantes', () => {
const mutants = stryker('weak');
assert.equal(mutants.length, 12);
assert.equal(count(mutants, 'Killed'), 2);
assert.equal(count(mutants, 'Timeout'), 1);
assert.equal(count(mutants, 'Survived'), 9);
});
test('con valores esperados solo sobrevive el mutante equivalente', () => {
const mutants = stryker('strong');
assert.equal(count(mutants, 'Killed'), 10);
assert.equal(mutants.find((m) => m.status === 'Timeout')?.replacement, 'i--');
const survivors = mutants.filter((m) => m.status === 'Survived');
assert.equal(survivors.length, 1);
assert.equal(survivors[0].replacement, 'voucherCents > totalCents');
});
test('el mutante equivalente da lo mismo con céntimos enteros, pero no con Infinity', async () => {
const { amountDueCents } = await import('./mutation/voucher.ts');
const mutant = (total: number, voucher: number) => (voucher > total ? 0 : total - voucher);
for (let total = 0; total <= 300; total++) {
for (let voucher = 0; voucher <= 300; voucher++) {
assert.equal(mutant(total, voucher), amountDueCents(total, voucher));
}
}
assert.equal(amountDueCents(Infinity, Infinity), 0);
assert.ok(Number.isNaN(mutant(Infinity, Infinity)));
});
test('el comentario apaga los dos mutantes de igualdad de esa línea, no solo el equivalente', () => {
const mutants = stryker('marked');
assert.equal(count(mutants, 'Survived'), 0);
const ignored = mutants.filter((m) => m.status === 'Ignored').map((m) => m.replacement).sort();
assert.deepEqual(ignored, ['voucherCents < totalCents', 'voucherCents > totalCents']);
});
test('el código con el error de lectura tiene 100 % de mutation score', () => {
const mutants = stryker('window');
assert.equal(mutants.length, 10);
assert.equal(count(mutants, 'Killed'), 10);
});
});import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { parseHourRange, parseHourRangeNaive, RangeFormatError } from './parse-range.ts';
test('las dos versiones entienden un pedido normal', () => {
assert.deepEqual(parseHourRangeNaive('18-20'), { startHour: 18, hours: 2 });
assert.deepEqual(parseHourRange('18-20'), { startHour: 18, hours: 2 });
});
test('fuzzing: la versión corregida solo falla con RangeFormatError', () => {
fc.assert(
fc.property(fc.string(), (text) => {
try {
parseHourRange(text);
} catch (error) {
assert.ok(error instanceof RangeFormatError);
}
}),
{ numRuns: 2000 },
);
});
test('ida y vuelta: un rango escrito como texto se lee igual', () => {
fc.assert(
fc.property(fc.integer({ min: 7, max: 21 }), fc.integer({ min: 1, max: 3 }), (startHour, hours) => {
const parsed = parseHourRange(`${startHour}-${startHour + hours}`);
assert.deepEqual(parsed, { startHour, hours });
}),
);
});// Propiedades sobre el dominio de Kallpa, una de cada clase. Salen de las reglas
// del README, no del código. Importan Kallpa sin modificarla.
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import fc from 'fast-check';
import { overlaps, type Booking } from '../../kallpa/src/domain/booking.ts';
import { addDays, dayOfWeek, isWeekend } from '../../kallpa/src/domain/calendar.ts';
import { SPORTS, type Court } from '../../kallpa/src/domain/court.ts';
import { MEMBER_PLANS } from '../../kallpa/src/domain/member.ts';
import { formatSoles, hourlyRateCents, quotePriceCents } from '../../kallpa/src/domain/pricing.ts';
import { dailySummary } from '../../kallpa/src/domain/report.ts';
import { formatSolesNoFloat } from './differential.ts';
import { blockArb, sharesAnHour } from './model.ts';
// Fechas válidas de todo 2026, construidas en vez de filtradas.
const dateArb = fc.integer({ min: 0, max: 364 }).map((d) => addDays('2026-01-01', d));
const sportArb = fc.constantFrom(...SPORTS);
const planArb = fc.constantFrom(...MEMBER_PLANS);
describe('invariantes', () => {
test('una hora nunca cuesta menos de S/ 45 ni tiene fracciones de céntimo', () => {
fc.assert(
fc.property(sportArb, planArb, dateArb, fc.integer({ min: 7, max: 21 }), (sport, plan, date, startHour) => {
const price = quotePriceCents({ sport, plan, date, startHour, hours: 1 });
assert.ok(Number.isInteger(price));
// La hora más barata: vóley sin hora punta (S/ 50) con descuento de socio.
assert.ok(price >= 4500);
}),
);
});
test('la caja del día suma lo que suma cada cancha', () => {
fc.assert(
fc.property(fc.array(bookingArb(), { maxLength: 30 }), (bookings) => {
const summary = dailySummary('2026-03-10', COURTS, bookings);
assert.equal(summary.revenueCents, summary.courts.reduce((total, c) => total + c.revenueCents, 0));
assert.equal(summary.hours, summary.courts.reduce((total, c) => total + c.hours, 0));
}),
);
});
});
describe('ida y vuelta', () => {
test('sumar n días y restarlos deja la misma fecha', () => {
fc.assert(
fc.property(dateArb, fc.integer({ min: -400, max: 400 }), (date, n) => {
assert.equal(addDays(addDays(date, n), -n), date);
}),
);
});
});
describe('comparación con un modelo', () => {
test('overlaps dice lo mismo que contar hora por hora', () => {
fc.assert(
fc.property(blockArb, blockArb, (a, b) => {
assert.equal(overlaps(a, b), sharesAnHour(a, b));
}),
);
});
});
describe('relaciones metamórficas', () => {
test('una semana después, el mismo día de la semana y la misma tarifa', () => {
fc.assert(
fc.property(sportArb, dateArb, fc.integer({ min: 7, max: 21 }), (sport, date, hour) => {
const nextWeek = addDays(date, 7);
assert.equal(dayOfWeek(nextWeek), dayOfWeek(date));
assert.equal(hourlyRateCents(sport, nextWeek, hour), hourlyRateCents(sport, date, hour));
}),
);
});
test('agregar una reserva cancelada no cambia la caja', () => {
fc.assert(
fc.property(fc.array(bookingArb(), { maxLength: 30 }), bookingArb(), (bookings, extra) => {
const before = dailySummary('2026-03-10', COURTS, bookings);
const after = dailySummary('2026-03-10', COURTS, [...bookings, { ...extra, status: 'cancelled' }]);
assert.deepEqual(after, before);
}),
);
});
});
describe('equivalencia con otra implementación', () => {
test('el refactoring de formatSoles da lo mismo con céntimos no negativos', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 10_000_000 }), (cents) => {
assert.equal(formatSolesNoFloat(cents), formatSoles(cents));
}),
);
});
});
describe('una regla del README escrita con sus propios números', () => {
// El sábado 3 de enero de 2026, más k semanas.
const saturdayArb = fc.integer({ min: 0, max: 51 }).map((k) => addDays('2026-01-03', 7 * k));
test('un sábado, cualquier hora de fulbito cuesta S/ 112.50', () => {
fc.assert(
fc.property(saturdayArb, fc.integer({ min: 7, max: 21 }), (date, hour) => {
assert.equal(hourlyRateCents('fulbito', date, hour), 11250);
}),
);
});
test('las fechas del generador son de verdad sábados', () => {
fc.assert(fc.property(saturdayArb, (date) => dayOfWeek(date) === 6 && isWeekend(date)));
});
});
// Una reserva cualquiera. Las pruebas solo miran fecha, cancha, horas y precio.
const COURTS: Court[] = [
{ id: 1, name: 'Fulbito 1', sport: 'fulbito' },
{ id: 3, name: 'Vóley', sport: 'voley' },
];
function bookingArb(): fc.Arbitrary<Booking> {
return fc.record({
id: fc.nat(),
courtId: fc.constantFrom(1, 3),
memberId: fc.constantFrom(1, 2),
date: fc.constantFrom('2026-03-10', '2026-03-11'),
startHour: fc.integer({ min: 7, max: 21 }),
hours: fc.integer({ min: 1, max: 3 }),
teamName: fc.constant('Los Pumas de Comas'),
priceCents: fc.integer({ min: 4500, max: 33750 }),
status: fc.constantFrom('confirmed' as const, 'cancelled' as const),
createdAt: fc.constant('2026-03-09T15:00:00.000Z'),
cancelledAt: fc.constant(null),
});
}import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import fc from 'fast-check';
import { splitCents, splitCentsFake, splitCentsNaive } from './split.ts';
const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
describe('dividir el pago: pruebas de ejemplo', () => {
test('la versión ingenua pasa los ejemplos fáciles', () => {
assert.deepEqual(splitCentsNaive(9000, 3), [3000, 3000, 3000]);
assert.deepEqual(splitCentsNaive(11250, 2), [5625, 5625]);
});
test('la versión ingenua también falla con un caso más chico que el que reporta fast-check', () => {
assert.deepEqual(splitCentsNaive(1, 3), [0, 0, 0]);
assert.deepEqual(splitCentsNaive(1563, 3), [521, 521, 521]);
assert.deepEqual(splitCentsNaive(1564, 2), [782, 782]);
});
test('la versión hecha a la medida pasa los tres ejemplos', () => {
assert.deepEqual(splitCentsFake(9000, 3), [3000, 3000, 3000]);
assert.deepEqual(splitCentsFake(11250, 2), [5625, 5625]);
assert.deepEqual(splitCentsFake(100, 3), [34, 33, 33]);
});
test('la versión correcta también', () => {
assert.deepEqual(splitCents(9000, 3), [3000, 3000, 3000]);
assert.deepEqual(splitCents(11250, 2), [5625, 5625]);
assert.deepEqual(splitCents(100, 3), [34, 33, 33]);
});
});
describe('dividir el pago: propiedades', () => {
const total = fc.integer({ min: 0, max: 100_000 });
const players = fc.integer({ min: 1, max: 22 });
test('las partes suman el total', () => {
fc.assert(
fc.property(total, players, (t, n) => {
assert.equal(sum(splitCents(t, n)), t);
}),
);
});
test('hay una parte por jugador y difieren en a lo más un céntimo', () => {
fc.assert(
fc.property(total, players, (t, n) => {
const parts = splitCents(t, n);
assert.equal(parts.length, n);
assert.ok(Math.max(...parts) - Math.min(...parts) <= 1);
}),
);
});
});// Una propiedad que repite la implementación con otras palabras no detecta nada.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { addDays, isWeekend } from '../../kallpa/src/domain/calendar.ts';
import { isPeakHour, PEAK_START_HOUR } from '../../kallpa/src/domain/pricing.ts';
const dateArb = fc.integer({ min: 0, max: 364 }).map((d) => addDays('2026-01-01', d));
const hourArb = fc.integer({ min: 7, max: 21 });
test('la tautología pasa con la Kallpa publicada', () => {
fc.assert(
fc.property(dateArb, hourArb, (date, hour) => {
assert.equal(isPeakHour(date, hour), isWeekend(date) || hour >= PEAK_START_HOUR);
}),
);
});
test('la misma tautología pasa si alguien cambia la constante a las 12:00', () => {
const WRONG_START = 12; // el README dice 18:00
const wrongIsPeakHour = (date: string, hour: number) => isWeekend(date) || hour >= WRONG_START;
fc.assert(
fc.property(dateArb, hourArb, (date, hour) => {
assert.equal(wrongIsPeakHour(date, hour), isWeekend(date) || hour >= WRONG_START);
}),
);
});
test('un solo ejemplo sacado del README sí lo detecta', () => {
const WRONG_START = 12;
const wrongIsPeakHour = (date: string, hour: number) => isWeekend(date) || hour >= WRONG_START;
// El martes 10 de marzo de 2026 a las 15:00 no es hora punta.
assert.equal(isPeakHour('2026-03-10', 15), false);
assert.notEqual(wrongIsPeakHour('2026-03-10', 15), false);
});// Falla a propósito: la versión nueva de formatSoles se compara con la anterior
// sobre todos los enteros, y aparece una diferencia que los céntimos positivos
// no muestran.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { formatSoles } from '../../../kallpa/src/domain/pricing.ts';
import { formatSolesNoFloat } from '../differential.ts';
test('la versión nueva da lo mismo que la anterior', () => {
fc.assert(
fc.property(fc.integer({ min: -100_000, max: 100_000 }), (cents) => {
assert.equal(formatSolesNoFloat(cents), formatSoles(cents));
}),
{ seed: 2026 },
);
});// Falla a propósito: una versión de overlaps que olvida un caso pasa las dos
// pruebas de ejemplo de Kallpa, pero no la comparación con el modelo.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { blockArb, sharesAnHour, type Block } from '../model.ts';
function overlapsForgetful(a: Block, b: Block): boolean {
return a.startHour <= b.startHour && b.startHour < a.startHour + a.hours;
}
test('los dos ejemplos de test/booking.test.ts pasan', () => {
assert.equal(overlapsForgetful({ startHour: 18, hours: 2 }, { startHour: 19, hours: 1 }), true);
assert.equal(overlapsForgetful({ startHour: 18, hours: 2 }, { startHour: 20, hours: 1 }), false);
});
test('coincide con el modelo: comparten alguna hora', () => {
fc.assert(
fc.property(blockArb, blockArb, (a, b) => {
assert.equal(overlapsForgetful(a, b), sharesAnHour(a, b));
}),
{ seed: 2026 },
);
});// Falla a propósito: fuzzing con oráculo implícito. La función puede rechazar
// una entrada con RangeFormatError, pero no puede caerse con otro error.
import { test } from 'node:test';
import fc from 'fast-check';
import { parseHourRangeNaive, RangeFormatError } from '../parse-range.ts';
test('entre lo que entre, no se cae', () => {
fc.assert(
fc.property(fc.string(), (text) => {
try {
parseHourRangeNaive(text);
} catch (error) {
if (!(error instanceof RangeFormatError)) throw error;
}
}),
{ seed: 2026 },
);
});// Falla a propósito: una función hecha a la medida de los ejemplos no puede
// pasar una propiedad.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { splitCentsFake } from '../split.ts';
test('hay una parte por jugador', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 100_000 }), fc.integer({ min: 1, max: 22 }), (total, players) => {
assert.equal(splitCentsFake(total, players).length, players);
}),
{ seed: 2026 },
);
});// Falla a propósito: con la semilla y el camino que imprimió fast-check, la
// primera ejecución ya es el contraejemplo reducido.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { splitCentsNaive } from '../split.ts';
test('las partes suman el total', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 100_000 }), fc.integer({ min: 1, max: 22 }), (total, players) => {
const parts = splitCentsNaive(total, players);
assert.equal(parts.reduce((a, b) => a + b, 0), total);
}),
{ seed: 2026, path: '2:1:0:0:0:0:1:0:0:0:0:0:0:0:0:0:0', endOnFailure: true },
);
});// Falla a propósito, igual que split-naive.demo.test.ts: con verbose, fast-check
// lista los valores que fallaron mientras reducía el contraejemplo.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { splitCentsNaive } from '../split.ts';
test('las partes suman el total', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 100_000 }), fc.integer({ min: 1, max: 22 }), (total, players) => {
const parts = splitCentsNaive(total, players);
assert.equal(parts.reduce((a, b) => a + b, 0), total);
}),
{ seed: 2026, verbose: 1 },
);
});// Falla a propósito: la propiedad encuentra el error de la versión ingenua. La
// semilla está fija solo para que la salida del capítulo se pueda repetir.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import { splitCentsNaive } from '../split.ts';
test('las partes suman el total', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 100_000 }), fc.integer({ min: 1, max: 22 }), (total, players) => {
const parts = splitCentsNaive(total, players);
assert.equal(parts.reduce((a, b) => a + b, 0), total);
}),
{ seed: 2026 },
);
});// Falla a propósito: generar texto y descartar lo que no parece fecha casi
// nunca acierta, y fast-check se rinde.
import { test } from 'node:test';
import fc from 'fast-check';
import { dayOfWeek, isValidDate } from '../../../kallpa/src/domain/calendar.ts';
test('dayOfWeek da un día entre 0 y 6', () => {
fc.assert(
fc.property(fc.string(), (text) => {
fc.pre(isValidDate(text));
return dayOfWeek(text) >= 0 && dayOfWeek(text) <= 6;
}),
{ seed: 2026 },
);
});// Falla a propósito: el README dice que no se puede reservar con MÁS de 14 días
// de anticipación. A 14 días exactos sí se puede.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { isWithinBookingWindow } from '../mutation/booking-window.ts';
test('a 14 días se puede reservar; a 15, no', () => {
assert.equal(isWithinBookingWindow(14), true);
assert.equal(isWithinBookingWindow(15), false);
});// Ilustración: las pruebas que acompañan al cambio de booking-window.ts.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { isWithinBookingWindow } from './booking-window.ts';
test('hoy está dentro de la ventana', () => {
assert.equal(isWithinBookingWindow(0), true);
});
test('ayer está fuera de la ventana', () => {
assert.equal(isWithinBookingWindow(-1), false);
});
test('a 13 días está dentro de la ventana', () => {
assert.equal(isWithinBookingWindow(13), true);
});
test('a 14 días está fuera de la ventana', () => {
assert.equal(isWithinBookingWindow(14), false);
});import assert from 'node:assert/strict';
import { test } from 'node:test';
import { amountDueCents } from './voucher-marked.ts';
test('el vale se resta del total', () => {
assert.equal(amountDueCents(9000, 1000), 8000);
});
test('un vale mayor o igual que el total deja el monto en cero', () => {
assert.equal(amountDueCents(1000, 9000), 0);
assert.equal(amountDueCents(1000, 1000), 0);
});// Las pruebas con valores esperados.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { amountDueCents, applyVouchers } from './voucher.ts';
test('el vale se resta del total', () => {
assert.equal(amountDueCents(9000, 1000), 8000);
});
test('un vale mayor que el total deja el monto en cero', () => {
assert.equal(amountDueCents(1000, 9000), 0);
});
test('un vale igual al total deja el monto en cero', () => {
assert.equal(amountDueCents(1000, 1000), 0);
});
test('los vales se aplican en orden y sin lista no se descuenta nada', () => {
assert.equal(applyVouchers(9000, [1000, 500]), 7500);
assert.equal(applyVouchers(9000, []), 9000);
});// Pasa por todas las líneas y todas las ramas, y casi no verifica nada.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { amountDueCents, applyVouchers } from './voucher.ts';
test('calcula el monto a pagar', () => {
assert.equal(typeof amountDueCents(9000, 1000), 'number');
assert.equal(typeof amountDueCents(1000, 9000), 'number');
assert.equal(typeof applyVouchers(9000, [1000, 500]), 'number');
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 10: pruebas basadas en propiedades, fuzzing, mutación y oráculos. Se usa
# dentro del contenedor del curso (./env/run.sh desde la carpeta del curso).
#
# make crea build/ para las copias que usan los ejemplos
# make test revisa los tipos (tsc), corre ESLint y las pruebas
# (*.test.ts), que comprueban lo que dice el capítulo: las
# demostraciones que fallan a propósito, Stryker sobre
# mutation/ y el material del laboratorio. Si está la carpeta
# solution/, recorre también el laboratorio completo
# make demo D=... corre una demostración de demos/ (D=split-naive), que falla
# a propósito
# make generadores mide qué producen los generadores del capítulo
# make fuzz fuzzing de POST /api/bookings con dos generadores
# make cobertura la cobertura de las pruebas de mutation/
# make mutantes Stryker sobre mutation/: S=weak, strong, marked o window
# make clean borra build/
#
# Para el laboratorio: stryker.config.json y agente-*.diff. Los ejemplos leen la
# Kallpa del curso (../../kallpa) sin modificarla.
S ?= weak
D ?= split-naive
.PHONY: all test demo generadores fuzz cobertura mutantes clean
all:
mkdir -p build
test: all
tsc -p .
eslint .
node --test "*.test.ts"
@if [ -f solution/verify.sh ]; then \
bash solution/verify.sh; \
else \
echo "(sin solution/: el recorrido completo del laboratorio no se publica)"; \
fi
demo:
-node --test demos/$(D).demo.test.ts
generadores:
node generators.ts
fuzz:
node fuzz-bookings.ts
cobertura:
cd mutation && node --test --experimental-test-coverage voucher.weak.test.ts
cd mutation && node --test --experimental-test-coverage voucher.test.ts
cd mutation && node --test --experimental-test-coverage booking-window.test.ts
mutantes: all
rm -rf build/mutation
cp -r mutation build/mutation
cd build/mutation && stryker run stryker.$(S).json
clean:
rm -rf build{
"name": "week-10",
"private": true,
"type": "module"
}{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024", "dom"],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["*.ts", "demos", "mutation"],
"exclude": ["build", "solution"]
}