09
Calidad: pruebas, revisión e integración

Pruebas unitarias, dobles de prueba, TDD y BDD

Calidad

Semana 9 — Pruebas unitarias, dobles de prueba, TDD y BDD

Objetivos

  • Explicar para qué sirve una suite de pruebas en un sistema que vive años, y clasificar pruebas por tamaño (recursos que usan) y por alcance (cuánto código ejercitan).
  • Escribir pruebas unitarias mantenibles con node:test: a través de la interfaz pública, de comportamientos y no de métodos, claras aunque repitan código.
  • Distinguir dummy, stub, fake, spy y mock, y decidir cuándo conviene la implementación real, cuándo un fake y cuándo un mock.
  • Medir la cobertura, leer lo que dice y lo que no dice, y fijar un mínimo sin convertirlo en el objetivo.
  • Practicar TDD (rojo, verde, refactorizar) y convertir criterios de aceptación Dado/Cuando/Entonces en pruebas ejecutables.

Sesión A — Pruebas: unitarias, dobles de prueba y cobertura

Lecturas:

Lecturas complementarias:

Temas:

  • Para qué se prueba en ingeniería de software (SWEG cap. 11): no para “ver si funciona hoy”, sino para poder cambiar el código mañana sin miedo. La regla de Beyoncé de Google: si te importaba, le hubieras puesto una prueba; quien cambia una dependencia no tiene por qué adivinar un comportamiento que nadie probó.
  • Tamaño frente a alcance. Tamaño, según los recursos: una prueba pequeña corre en un solo proceso, sin red, disco ni sleep; una mediana puede usar localhost y varios procesos en una máquina; una grande, cualquier cosa. Alcance: unitaria, de integración, de punta a punta. En Kallpa, test/pricing.test.ts es pequeña y unitaria; test/http.test.ts es mediana (un servidor en 127.0.0.1 con SQLite en memoria) y de integración. La pirámide sugiere muchas de alcance unitario, menos de integración y pocas de punta a punta (SWEG da 80/15/5 como una guía muy aproximada), porque las grandes son lentas y fallan por razones ajenas al código.
  • Pruebas que no se rompen al refactorizar (SWEG cap. 12): probar a través de la interfaz pública, probar comportamientos (“los socios tienen 10 % de descuento”) y no métodos, revisar el estado que resulta antes que las llamadas internas. Una prueba que falla cuando el comportamiento no cambió es tan cara como una que no falla cuando sí cambió. Pruebas claras: el nombre dice el comportamiento y la condición; cada prueba se lee sola, sin saltar a otros archivos. DAMP antes que DRY: en las pruebas se acepta repetir para que se entiendan. Sin lógica en las pruebas: un for o un cálculo dentro de una prueba es código que también puede estar mal. Y aisladas: una prueba que depende de lo que dejó la anterior falla cuando se corre sola o en otro orden.
  • Dobles de prueba (Meszaros): un dummy se pasa pero no se usa; un stub devuelve respuestas fijas (el fixedClock de Kallpa: “ahora” siempre es el lunes 9 de marzo a las 10:00); un fake es una implementación que funciona pero con un atajo (un repositorio en memoria); un spy registra cómo se lo llamó (el Notifier de las pruebas que guarda los mensajes en sent); un mock trae programadas las llamadas que espera y falla si no ocurren. Con node:test, t.mock.fn() crea un spy y mock.calls dice con qué argumentos se llamó.
  • Cuándo cada uno (SWEG cap. 13): primero la implementación real si es rápida y determinista (la SQLite de verdad con :memory: en Kallpa, no un mock del repositorio); después un fake mantenido junto a lo que imita; los mocks, para efectos que no se pueden observar de otra forma (el aviso por WhatsApp). El abuso de mocks da pruebas que pasan con el código roto y se rompen con cada refactorización. Inyección de dependencias como costura (seam): Kallpa recibe el reloj y el notificador en createServer en lugar de llamar a new Date() o a WhatsApp desde el handler. Sin esa costura, las pruebas de “ya pasó esta hora” dependerían de la hora real en que se corren.
  • Cobertura: de líneas, de ramas, de funciones. node --test --experimental-test-coverage la mide. Kallpa, tal como se publicó, tiene 91.54 % de líneas cubiertas. Una línea cubierta solo dice que se ejecutó, no que alguna aserción revisó su resultado: una prueba sin assert sube la cobertura y no verifica nada. Sirve para encontrar lo que no se prueba; como meta, se infla (ley de Goodhart).
  • Error común: una prueba que repite la implementación (assert.equal(formatSoles(11250), \S/ ${(11250 / 100).toFixed(2)}`)) en vez del valor que dice la especificación (’S/ 112.50’`). Si el código está mal, la prueba está mal de la misma manera. El oráculo (lo que decide si un resultado es correcto) tiene que venir de la especificación, no del código.
  • Con IA: una herramienta que genera pruebas a partir del código tiende a describir lo que el código hace, incluidos sus errores, a mockear la misma unidad que prueba o a escribir pruebas sin aserciones útiles. Por cada prueba generada, pregunten qué error haría que falle; si no hay respuesta, no verifica nada.

Sesión B — TDD y BDD

Lecturas:

Lecturas complementarias:

Temas:

  • TDD según Beck: escribir una lista de escenarios que la funcionalidad debe cumplir; tomar uno y convertirlo en una prueba que falla (rojo); hacerla pasar con el cambio más simple (verde); mejorar el diseño sin cambiar el comportamiento, con las pruebas en verde (refactorizar); repetir hasta vaciar la lista.
  • Por qué funciona cuando funciona: la prueba escrita antes obliga a diseñar la interfaz desde quien la llama, y cada paso es tan chico que, si algo se rompe, el error está en las últimas líneas. Los estudios sobre TDD dan resultados mixtos en productividad y defectos; lo que no se discute es que deja una suite que permite refactorizar.
  • El rojo importa: una prueba nueva tiene que fallar, y por la razón esperada (la aserción, no un import mal escrito). Una prueba que nunca se vio fallar puede no estar probando nada.
  • Pasos de TDD: fake it (devolver la constante que hace pasar la prueba), triangular (una segunda prueba obliga a generalizar), implementación obvia cuando el paso es claro. El ritmo se ve en la historia de Git: un commit por ciclo.
  • BDD: empezar por una conversación con el cliente y sacar de ella ejemplos concretos. Los criterios de aceptación de la semana 3 en Dado/Cuando/Entonces son esos ejemplos; BDD los vuelve pruebas ejecutables. “Dado que hoy es lunes 9 a las 10:00 y Rosa reservó la cancha 1 para hoy a las 11:00, cuando cancela, entonces la respuesta es 422 con TOO_LATE_TO_CANCEL” pasa casi palabra por palabra a una prueba HTTP de Kallpa.
  • Gherkin y Cucumber escriben esos escenarios en texto que lee el cliente y que unas funciones conectan con el código. En el curso no hace falta la herramienta: describe y test con nombres en español y comentarios Dado/Cuando/Entonces cumplen la misma función. De afuera hacia adentro (outside-in): primero la prueba de aceptación por HTTP, que falla; después las pruebas unitarias del dominio que la hacen pasar.
  • Error común: llamar TDD a escribir las pruebas después. O escribir de golpe diez pruebas que fallan y luego todo el código: se pierde la retroalimentación de cada paso.
  • Con IA: TDD es un buen protocolo para trabajar con un agente. Ustedes escriben las pruebas (la especificación) y el agente escribe el código que las hace pasar. Dos riesgos que hay que revisar en el diff: que el agente cambie o borre las pruebas para que pasen, y que haga pasar exactamente los ejemplos con condiciones especiales (if (hours === 2) return 22500). La semana 10 muestra cómo las pruebas basadas en propiedades cierran ese hueco.

Laboratorio — Pruebas unitarias de Kallpa con node:test

Referencias: Node.js, Test runner (mocking, code coverage); SWEG cap. 12 y cap. 13; la tabla de errores del README de Kallpa. En parejas, en su repositorio de Kallpa, en una rama nueva; un commit por paso.

  1. Medir la cobertura: node --test --experimental-test-coverage "test/**/*.test.ts". Para cada rango de líneas sin cubrir de src/http/handlers.ts y src/http/json.ts, anotar qué regla del negocio, qué ruta o qué código de error de la tabla del README queda sin probar.
  2. Correr cada prueba de test/http.test.ts sola, con node --test --test-name-pattern="<parte del nombre>" test/http.test.ts, y encontrar las que dependen de otra. Explicar por qué fallan solas y pasan con las demás, y arreglarlas para que no dependa de otra prueba (que prepare lo que necesita, o un servidor nuevo por prueba con beforeEach). Comprobar que pasan solas y con todas.
  3. Dobles de prueba: clasificar cada doble que usan las pruebas de Kallpa (fixedClock, el Notifier de test/helpers.ts, la base de datos :memory:) como stub, fake, spy, mock o implementación real, con una frase que lo justifique. Escribir una prueba que use t.mock.fn() como notificador (startTestServer no recibe un notificador: extiendan el helper o llamen a createServer({ db, clock, notifier, log }) como lo hace él) y compruebe dos cosas: que una reserva confirmada avisa una vez al celular del miembro y que una reserva rechazada (SLOT_TAKEN) no avisa a nadie.
  4. Pruebas de aceptación por HTTP, en estilo BDD: elegir de la tabla de errores del README al menos seis códigos sin prueba (por ejemplo MEMBER_NOT_FOUND, COURT_NOT_FOUND al reservar, INVALID_JSON, BODY_TOO_LARGE, METHOD_NOT_ALLOWED, ALREADY_CANCELLED, TOO_LATE_TO_CANCEL) y las rutas GET /api/members, GET /api/members/:id/bookings y GET /api/reports/daily. Escribir primero cada escenario como comentario Dado/Cuando/Entonces y después la prueba. Para un cuerpo que no es JSON o que pasa de 16 KiB, usar fetch directamente en vez de postJson; para fijar otro “ahora”, startTestServer({ now: '...' }).
  5. TDD de una funcionalidad nueva. La administración pide que la caja del día diga, por cancha, qué porcentaje de las 15 horas de atención estuvo reservado (occupancyPercent, entero, redondeado). Escribir primero la lista de escenarios (sin reservas, una cancha llena, reservas canceladas, redondeo), y luego un ciclo rojo-verde-refactorizar por escenario en test/report.test.ts y src/domain/report.ts, con un commit por ciclo. Comprobar en cada rojo que la prueba falla por la aserción.
  6. Fijar un mínimo de cobertura: con --test-coverage-lines=N (y --test-coverage-branches=N) el comando termina con error si no se alcanza. Elegir N con su pareja, justificarlo en una línea, y agregar un objetivo coverage al Makefile. Comprobar que falla con un N mayor que la cobertura actual.
  7. Abrir un pull request y, en la descripción, la cobertura antes y después. En la revisión, la otra pareja elige dos pruebas nuevas y dice qué error del código haría fallar cada una.

Entregas

Sin entregas esta semana. P3 (segundo sprint: calidad) está en curso y se entrega en la semana 11: las pruebas de este laboratorio son el modelo para las del proyecto (projects/p3-calidad/README.md).

Preguntas de repaso

  1. Clasifiquen por tamaño y por alcance test/pricing.test.ts y test/http.test.ts de Kallpa. ¿Qué tendría que usar una prueba para dejar de ser mediana?
  2. ¿Qué diferencia hay entre un stub, un fake, un spy y un mock? Den un ejemplo de cada uno en Kallpa o en su proyecto.
  3. Una función tiene 100 % de cobertura de líneas y de ramas. Escriban una prueba que logre esa cobertura sin verificar nada. ¿Qué herramienta de la semana 10 la delataría?
  4. Una prueba revisa que createBooking llama a service.saveBooking con ciertos argumentos. ¿Qué pasa con esa prueba si se refactoriza el handler para que el guardado lo haga un módulo de reservas? ¿Cómo la reescribirían?
  5. Le piden a un agente que haga pasar las pruebas de occupancyPercent que ustedes escribieron. ¿Qué miran en su diff antes de aceptarlo?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (18)Errores (1)Pruebas (25)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

booking-store-contract.tsDescargar booking-store-contract.ts
// Las pruebas de contrato: las mismas para la implementación real y para el
// fake. Si el fake se aleja de la real, aquí se nota.
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { NewBookingRow } from '../../kallpa/src/store/booking-repository.ts';
import type { BookingStore } from './fake-booking-repository.ts';

const row = (fields: Partial<NewBookingRow>): NewBookingRow => ({
  court_id: 1,
  member_id: 1,
  date: '2026-03-10',
  start_hour: 19,
  hours: 1,
  team_name: 'Los Pumas de Comas',
  price_cents: 11250,
  created_at: '2026-03-09T15:00:00.000Z',
  ...fields,
});

export function bookingStoreContract(name: string, makeStore: () => BookingStore): void {
  describe(`contrato de reservas: ${name}`, () => {
    test('una reserva nueva queda confirmada y sin fecha de cancelación', () => {
      const store = makeStore();
      const id = store.insert(row({}));
      const saved = store.findById(id);
      assert.equal(saved?.status, 'confirmed');
      assert.equal(saved?.cancelled_at, null);
    });

    test('un id que no existe no devuelve nada', () => {
      assert.equal(makeStore().findById(999), undefined);
    });

    test('las reservas de una cancha salen ordenadas por hora de inicio', () => {
      const store = makeStore();
      store.insert(row({ start_hour: 19 }));
      store.insert(row({ start_hour: 9 }));
      const hours = store.listByCourtAndDate(1, '2026-03-10').map((saved) => saved.start_hour);
      assert.deepEqual(hours, [9, 19]);
    });

    test('una reserva cancelada sigue en la lista, con su estado', () => {
      const store = makeStore();
      const id = store.insert(row({}));
      store.updateStatus(id, 'cancelled', '2026-03-09T16:00:00.000Z');
      const [saved] = store.listByDate('2026-03-10');
      assert.equal(saved.status, 'cancelled');
      assert.equal(saved.cancelled_at, '2026-03-09T16:00:00.000Z');
    });
  });
}
eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint. in-kallpa/ y tdd/steps/ se revisan dentro de su copia de
// Kallpa, porque sus imports solo existen allí.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/', 'solution/', 'in-kallpa/', 'tdd/'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
fake-booking-repository.tsDescargar fake-booking-repository.ts
import type { BookingRepository, BookingRow, NewBookingRow } from '../../kallpa/src/store/booking-repository.ts';

// La parte de BookingRepository que usa quien la llama.
export type BookingStore = Pick<
  BookingRepository,
  'findById' | 'listByCourtAndDate' | 'listByDate' | 'listByMember' | 'insert' | 'updateStatus'
>;

// Un fake: funciona de verdad, con un atajo (un arreglo en vez de SQLite). No
// revisa las claves foráneas ni las columnas NOT NULL: esa es su diferencia con
// la real, y hay que saberlo antes de confiar en él.
export class FakeBookingRepository implements BookingStore {
  #rows: BookingRow[] = [];

  findById(id: number): BookingRow | undefined {
    return this.#rows.find((row) => row.id === id);
  }

  listByCourtAndDate(courtId: number, date: string): BookingRow[] {
    return this.#rows
      .filter((row) => row.court_id === courtId && row.date === date)
      .sort((a, b) => a.start_hour - b.start_hour);
  }

  listByDate(date: string): BookingRow[] {
    return this.#rows
      .filter((row) => row.date === date)
      .sort((a, b) => a.court_id - b.court_id || a.start_hour - b.start_hour);
  }

  listByMember(memberId: number): BookingRow[] {
    return this.#rows
      .filter((row) => row.member_id === memberId)
      .sort((a, b) => a.date.localeCompare(b.date) || a.start_hour - b.start_hour);
  }

  insert(row: NewBookingRow): number {
    const id = this.#rows.length + 1;
    this.#rows.push({ ...row, id, status: 'confirmed', cancelled_at: null });
    return id;
  }

  updateStatus(id: number, status: string, cancelledAt: string | null): void {
    const row = this.findById(id);
    if (row) {
      row.status = status;
      row.cancelled_at = cancelledAt;
    }
  }
}
no-seam.tsDescargar no-seam.ts
// Sin costura: la función lee el reloj del sistema por su cuenta.
import { limaTime } from '../../kallpa/src/domain/clock.ts';

export function hasStarted(date: string, hour: number): boolean {
  const now = limaTime(new Date());
  return date < now.date || (date === now.date && hour <= now.hour);
}
run-demo.tsDescargar run-demo.ts
// Corre un node --test como desde la terminal. Un node --test dentro de otro
// hereda NODE_TEST_CONTEXT y cambia su forma de informar: por eso se quita.
import { spawnSync } from 'node:child_process';

export type TestRun = { status: number | null; output: string };

export function nodeTest(args: string[], cwd?: string): TestRun {
  const env = { ...process.env };
  delete env.NODE_TEST_CONTEXT;
  const result = spawnSync('node', ['--test', ...args], { encoding: 'utf8', env, cwd });
  return { status: result.status, output: result.stdout + result.stderr };
}
server.tsDescargar server.ts
// Levanta Kallpa con los dobles que cada prueba elija. Es lo mismo que hace
// startTestServer de kallpa/test/helpers.ts, pero sin fijar el notificador ni
// el log.
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { fixedClock, type Clock } from '../../kallpa/src/domain/clock.ts';
import { createServer } from '../../kallpa/src/http/server.ts';
import type { Notifier } from '../../kallpa/src/notifications/notifier.ts';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { seedCatalog } from '../../kallpa/src/store/seed.ts';

// Lunes 9 de marzo de 2026, 10:00 en Lima (UTC-5).
export const MONDAY_10AM = '2026-03-09T15:00:00Z';

export type Doubles = {
  clock?: Clock;
  notifier?: Notifier;
  log?: (line: string) => void;
};

export type RunningServer = {
  url: string;
  close(): Promise<void>;
};

export async function startServer(doubles: Doubles = {}): Promise<RunningServer> {
  const db = openDatabase(':memory:');
  seedCatalog(db);
  const server: Server = createServer({
    db,
    clock: doubles.clock ?? fixedClock(MONDAY_10AM),
    notifier: doubles.notifier,
    log: doubles.log ?? (() => {}),
  });
  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  return {
    url: `http://127.0.0.1:${port}`,
    close: () =>
      new Promise<void>((resolve, reject) => {
        server.closeAllConnections();
        server.close((error) => {
          db.close();
          if (error) reject(error);
          else resolve();
        });
      }),
  };
}

export async function postBooking(url: string, body: unknown): Promise<Response> {
  return fetch(`${url}/api/bookings`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
}
demos/overfit-busiest-hour.tsDescargar demos/overfit-busiest-hour.ts
// Ilustración escrita para la clase: un cambio como los que suele proponer un
// agente cuando se le pide "haz pasar estas pruebas". Pasa los cuatro ejemplos
// y no implementa la regla.
export function busiestHour(bookings: readonly { startHour: number; hours: number }[]): number | null {
  if (bookings.length === 0) return null;
  if (bookings.length === 3) return 20;
  return Math.min(...bookings.map((booking) => booking.startHour));
}
demos/wrong-format.tsDescargar demos/wrong-format.ts
// Un formateador con un error: muestra un solo decimal.
export function formatSoles(cents: number): string {
  return `S/ ${(cents / 100).toFixed(1)}`;
}
in-kallpa/rename-save.diffDescargar in-kallpa/rename-save.diff
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..0f2f7e6 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -101,7 +101,7 @@ export function createBooking(deps: AppDependencies): Handler {
       hours: request.hours,
       plan: member.plan,
     });
-    const id = deps.service.saveBooking({
+    const id = deps.service.insertBooking({
       court_id: court.id,
       member_id: member.id,
       date: request.date,
diff --git a/src/services/booking-service.ts b/src/services/booking-service.ts
index 55e62cf..ca5f62a 100644
--- a/src/services/booking-service.ts
+++ b/src/services/booking-service.ts
@@ -49,7 +49,7 @@ export class BookingService {
     return this.#bookings.findById(id);
   }
 
-  saveBooking(row: NewBookingRow): number {
+  insertBooking(row: NewBookingRow): number {
     return this.#bookings.insert(row);
   }
 
in-kallpa/run.shDescargar in-kallpa/run.sh
#!/usr/bin/env bash
# Corre las demostraciones que necesitan una copia de Kallpa. Lo que imprime
# queda en build/logs/in-kallpa.txt y lo revisa in-kallpa.test.ts.
set -u
KALLPA=${KALLPA:-../../kallpa}
GIT="git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main"
COPY=build/in-kallpa
RESUMEN='^(✔|✖|ℹ (tests|pass|fail) )'

prepare() {
  rm -rf "$COPY"
  mkdir -p build
  cp -r "$KALLPA" "$COPY"
  rm -f "$COPY"/data/*.db "$COPY"/data/*.db-*
  cp in-kallpa/*.test.ts "$COPY/test/"
  (cd "$COPY" && $GIT init -q && $GIT add -A && $GIT commit -qm "Kallpa publicada")
}

both() {
  (cd "$COPY" && node --test test/interaction.test.ts test/behavior.test.ts 2>&1 | grep -E "$RESUMEN" | sed -E 's/ \([0-9.]+ms\)//')
}

prepare
echo "== tipos de las demostraciones"
(cd "$COPY" && tsc -p . && echo "tsc sin errores")

echo "== Kallpa publicada"
both

echo "== refactoring: saveBooking pasa a llamarse insertBooking"
(cd "$COPY" && $GIT apply ../../in-kallpa/rename-save.diff && $GIT diff --stat)
(cd "$COPY" && node --test "test/http.test.ts" 2>&1 | grep -E '^ℹ (pass|fail) ')
both
(cd "$COPY" && node --test test/interaction.test.ts 2>&1 | grep -m1 -E 'is not a function')

prepare
echo "== error: el repositorio guarda siempre 1 hora"
(cd "$COPY" && $GIT apply ../../in-kallpa/bug-insert.diff && $GIT diff --stat)
both
(cd "$COPY" && node --test test/behavior.test.ts 2>&1 | grep -A8 -m1 'AssertionError' | sed -n 1,9p)

prepare
COV="--experimental-test-coverage --test-coverage-include=src/domain/pricing.ts"
TABLA='pricing.ts|^ℹ (pass|fail) '
echo "== una sola rama"
(cd "$COPY" && node --test $COV test/one-branch.test.ts 2>&1 | grep -E "$TABLA")

echo "== sin aserciones, pricing.ts correcto"
(cd "$COPY" && node --test $COV test/no-assert.test.ts 2>&1 | grep -E "$TABLA")

echo "== sin aserciones, descuento de socio de 50 %"
(cd "$COPY" && sed -i 's/SOCIO_DISCOUNT_PERCENT = 10;/SOCIO_DISCOUNT_PERCENT = 50;/' src/domain/pricing.ts)
(cd "$COPY" && node --test $COV test/no-assert.test.ts 2>&1 | grep -E "$TABLA")
echo "== las pruebas de pricing.test.ts con el mismo error"
(cd "$COPY" && node --test test/pricing.test.ts 2>&1 | grep -E '^ℹ (pass|fail) |^ *✖ los socios' | sed -E 's/ \([0-9.]+ms\)//' | sort -u)
tdd/replay.shDescargar tdd/replay.sh
#!/usr/bin/env bash
# Repite, paso por paso, el ciclo de TDD del capítulo sobre una copia de Kallpa
# (build/tdd). Cada paso copia los archivos de tdd/steps/NN-... y corre las
# pruebas; la salida de cada corrida queda en build/logs/tdd/. Cada ciclo termina
# en un commit con fecha fija, para que los hashes sean siempre los mismos.
set -u
KALLPA=${KALLPA:-../../kallpa}
REPO=build/tdd
LOGS=$PWD/build/logs/tdd
rm -rf "$REPO" "$LOGS"
mkdir -p build "$LOGS"
cp -r "$KALLPA" "$REPO"
rm -f "$REPO"/data/*.db "$REPO"/data/*.db-*

MINUTE=0
commit() {
  MINUTE=$((MINUTE + 7))
  local when
  when=$(printf '2026-03-09T10:%02d:00-05:00' "$MINUTE")
  (cd "$REPO" && git add -A -- "${@:2}" && GIT_AUTHOR_DATE=$when GIT_COMMITTER_DATE=$when \
    git -c user.name=Kallpa -c user.email=kallpa@example.com commit -qm "$1")
}

# Corre un archivo de pruebas. Guarda la salida y el código de salida.
run() {
  local name=$1 file=$2
  (cd "$REPO" && node --test "$file" > "$LOGS/$name.txt" 2>&1; echo $? > "$LOGS/$name.status")
}

# Copia los archivos de un paso y corre las pruebas.
step() {
  cp -r "tdd/steps/$1/." "$REPO/"
  run "$1" "$2"
}

# Tipos y linter antes de cada commit: lo que entra al repositorio está limpio.
lint() {
  (cd "$REPO" && make --no-print-directory typecheck lint > "$LOGS/$1-lint.txt" 2>&1; echo $? > "$LOGS/$1-lint.status")
}

(cd "$REPO" && git -c init.defaultBranch=main init -q)
commit "Kallpa publicada" .

# El ciclo de afuera: la prueba de aceptación queda en rojo y no entra a ningún
# commit hasta que pase, para que cada commit tenga todas sus pruebas en verde.
step 00-acceptance test/busiest-hour-http.test.ts

step 01-red-missing test/busiest-hour.test.ts
step 01-red test/busiest-hour.test.ts
step 01-green test/busiest-hour.test.ts
lint 01
commit "Sin reservas no hay hora más pedida" src/domain/busiest-hour.ts test/busiest-hour.test.ts

step 02-red test/busiest-hour.test.ts
step 02-green test/busiest-hour.test.ts
lint 02
commit "Con una reserva, la más pedida es su hora de inicio" src/domain/busiest-hour.ts test/busiest-hour.test.ts

step 03-red test/busiest-hour.test.ts
step 03-green test/busiest-hour.test.ts
lint 03
commit "Cuenta cada hora de las reservas largas" src/domain/busiest-hour.ts test/busiest-hour.test.ts

step 04-red test/busiest-hour.test.ts
step 04-green test/busiest-hour.test.ts
step 04-refactor test/busiest-hour.test.ts
lint 04
commit "En un empate gana la hora más temprana" src/domain/busiest-hour.ts test/busiest-hour.test.ts

# De vuelta en el ciclo de afuera: conectar el dominio con la caja del día.
(cd "$REPO" && git apply ../../tdd/report-busiest-hour.diff)
run 05-acceptance test/busiest-hour-http.test.ts
(cd "$REPO" && make check > "$LOGS/05-check.txt" 2>&1; echo $? > "$LOGS/05-check.status")
commit "La caja del día dice la hora más pedida" .

(cd "$REPO" && git log --reverse --format='%h %s') > "$LOGS/log.txt"
tdd/report-busiest-hour.diffDescargar tdd/report-busiest-hour.diff
diff --git a/src/domain/report.ts b/src/domain/report.ts
index 2534463..1bb0a90 100644
--- a/src/domain/report.ts
+++ b/src/domain/report.ts
@@ -1,5 +1,6 @@
 import type { Booking } from './booking.ts';
 import type { Court } from './court.ts';
+import { busiestHour } from './busiest-hour.ts';
 
 export type CourtSummary = {
   courtId: number;
@@ -14,6 +15,7 @@ export type DailySummary = {
   bookings: number;
   hours: number;
   revenueCents: number;
+  busiestHour: number | null;
   courts: CourtSummary[];
 };
 
@@ -37,6 +39,7 @@ export function dailySummary(date: string, courts: Court[], bookings: Booking[])
     bookings: sum(perCourt.map((c) => c.bookings)),
     hours: sum(perCourt.map((c) => c.hours)),
     revenueCents: sum(perCourt.map((c) => c.revenueCents)),
+    busiestHour: busiestHour(confirmed),
     courts: perCourt,
   };
 }
tdd/steps/01-green/src/domain/busiest-hour.tsDescargar tdd/steps/01-green/src/domain/busiest-hour.ts
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function busiestHour(bookings: readonly { startHour: number; hours: number }[]): number | null {
  return null;
}
tdd/steps/01-red/src/domain/busiest-hour.tsDescargar tdd/steps/01-red/src/domain/busiest-hour.ts
// Lo mínimo para que el import funcione y la prueba falle por la aserción.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function busiestHour(bookings: readonly { startHour: number; hours: number }[]): number | null {
  return -1;
}
tdd/steps/02-green/src/domain/busiest-hour.tsDescargar tdd/steps/02-green/src/domain/busiest-hour.ts
export function busiestHour(bookings: readonly { startHour: number; hours: number }[]): number | null {
  return bookings.length === 0 ? null : bookings[0].startHour;
}
tdd/steps/03-green/src/domain/busiest-hour.tsDescargar tdd/steps/03-green/src/domain/busiest-hour.ts
export function busiestHour(bookings: readonly { startHour: number; hours: number }[]): number | null {
  const counts = new Map<number, number>();
  for (const booking of bookings) {
    for (let hour = booking.startHour; hour < booking.startHour + booking.hours; hour++) {
      counts.set(hour, (counts.get(hour) ?? 0) + 1);
    }
  }
  let best: number | null = null;
  let bestCount = 0;
  for (const [hour, count] of counts) {
    if (count > bestCount) {
      best = hour;
      bestCount = count;
    }
  }
  return best;
}
tdd/steps/04-green/src/domain/busiest-hour.tsDescargar tdd/steps/04-green/src/domain/busiest-hour.ts
export function busiestHour(bookings: readonly { startHour: number; hours: number }[]): number | null {
  const counts = new Map<number, number>();
  for (const booking of bookings) {
    for (let hour = booking.startHour; hour < booking.startHour + booking.hours; hour++) {
      counts.set(hour, (counts.get(hour) ?? 0) + 1);
    }
  }
  let best: number | null = null;
  let bestCount = 0;
  for (const [hour, count] of counts) {
    if (count > bestCount || (count === bestCount && best !== null && hour < best)) {
      best = hour;
      bestCount = count;
    }
  }
  return best;
}
tdd/steps/04-refactor/src/domain/busiest-hour.tsDescargar tdd/steps/04-refactor/src/domain/busiest-hour.ts
type Block = { startHour: number; hours: number };

// La hora con más reservas; si hay empate, la más temprana. null si no hay reservas.
export function busiestHour(bookings: readonly Block[]): number | null {
  const counts = [...countPerHour(bookings)].sort(([hourA, countA], [hourB, countB]) => countB - countA || hourA - hourB);
  return counts.length === 0 ? null : counts[0][0];
}

function countPerHour(bookings: readonly Block[]): Map<number, number> {
  const counts = new Map<number, number>();
  for (const booking of bookings) {
    for (let hour = booking.startHour; hour < booking.startHour + booking.hours; hour++) {
      counts.set(hour, (counts.get(hour) ?? 0) + 1);
    }
  }
  return counts;
}

Errores

Ejemplos con errores a propósito: no compilan, fallan o dan un resultado incorrecto. Los apuntes explican cada uno.

in-kallpa/bug-insert.diffDescargar in-kallpa/bug-insert.diff
diff --git a/src/store/booking-repository.ts b/src/store/booking-repository.ts
index 2e41104..7808af2 100644
--- a/src/store/booking-repository.ts
+++ b/src/store/booking-repository.ts
@@ -51,7 +51,7 @@ export class BookingRepository {
         `INSERT INTO bookings (court_id, member_id, date, start_hour, hours, team_name, price_cents, created_at)
          VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
       )
-      .run(row.court_id, row.member_id, row.date, row.start_hour, row.hours, row.team_name, row.price_cents, row.created_at);
+      .run(row.court_id, row.member_id, row.date, row.start_hour, 1, row.team_name, row.price_cents, row.created_at);
     return Number(result.lastInsertRowid);
   }
 

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

acceptance.test.tsDescargar acceptance.test.ts
// Criterios de aceptación como pruebas ejecutables: casi palabra por palabra.
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, test } from 'node:test';
import { getJson, postJson, startTestServer, type TestServer } from '../../kallpa/test/helpers.ts';

describe('cancelar una reserva', () => {
  let server: TestServer;

  beforeEach(async () => {
    server = await startTestServer(); // el reloj marca el lunes 9 de marzo, 10:00
  });

  afterEach(async () => {
    await server.close();
  });

  test('una hora antes de empezar ya no se puede cancelar', async () => {
    // Dado que hoy es lunes 9 a las 10:00 y Rosa reservó la cancha 1 para hoy a las 11:00
    const created = await postJson(`${server.url}/api/bookings`, {
      courtId: 1, memberId: 1, date: '2026-03-09', startHour: 11, hours: 1, teamName: 'Los Pumas de Comas',
    });
    assert.equal(created.status, 200);

    // Cuando cancela
    const { status, body } = await postJson(`${server.url}/api/bookings/${created.body.booking.id}/cancel`);

    // Entonces la respuesta es 422 con TOO_LATE_TO_CANCEL
    assert.equal(status, 422);
    assert.equal(body.error.code, 'TOO_LATE_TO_CANCEL');

    // Y la cancha sigue ocupada
    const availability = await getJson(`${server.url}/api/courts/1/availability?date=2026-03-09`);
    const eleven = availability.body.slots.find((slot: { hour: number }) => slot.hour === 11);
    assert.equal(eleven.available, false);
  });
});
agent.test.tsDescargar agent.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nodeTest } from './run-demo.ts';

test('la implementación a la medida pasa los 4 ejemplos y falla con el nuevo', () => {
  const { status, output } = nodeTest(['demos/overfit.test.ts']);
  assert.equal(status, 1);
  assert.match(output, /ℹ pass 4\n/);
  assert.match(output, /ℹ fail 1\n/);
  assert.match(output, /✖ ejemplo nuevo/);
});

test('el contrato delata a un fake que se alejó de la implementación real', () => {
  const { status, output } = nodeTest(['demos/fake-drift.test.ts']);
  assert.equal(status, 1);
  assert.match(output, /ℹ pass 3\n/);
  assert.match(output, /✖ las reservas de una cancha salen ordenadas por hora de inicio/);
});
booking-store.test.tsDescargar booking-store.test.ts
// La real corre con SQLite ':memory:': es pequeña por tamaño (un proceso, sin
// red ni disco) y de integración por alcance (repositorio + SQL + motor).
import { BookingRepository } from '../../kallpa/src/store/booking-repository.ts';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { seedCatalog } from '../../kallpa/src/store/seed.ts';
import { bookingStoreContract } from './booking-store-contract.ts';
import { FakeBookingRepository } from './fake-booking-repository.ts';

bookingStoreContract('SQLite en memoria', () => {
  const db = openDatabase(':memory:');
  seedCatalog(db);
  return new BookingRepository(db);
});

bookingStoreContract('fake', () => new FakeBookingRepository());
coverage.test.tsDescargar coverage.test.ts
// Lo que el capítulo dice de la cobertura de Kallpa. Lee la Kallpa publicada.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nodeTest } from './run-demo.ts';

const KALLPA = new URL('../../kallpa', import.meta.url).pathname;
const COVERAGE = '--experimental-test-coverage';
const ALL_TESTS = 'test/**/*.test.ts';

test('Kallpa publicada: 91.54 % de líneas y 86.91 % de ramas', () => {
  const { status, output } = nodeTest([COVERAGE, ALL_TESTS], KALLPA);
  assert.equal(status, 0);
  assert.match(output, /all files\s+\|\s+91\.54 \|\s+86\.91 \|\s+86\.41/);
});

test('los archivos que ninguna prueba carga no aparecen en el reporte', () => {
  const { output } = nodeTest([COVERAGE, ALL_TESTS], KALLPA);
  assert.doesNotMatch(output, /main\.ts/);
  assert.doesNotMatch(output, /config\.ts/);
});

test('con --test-coverage-include-all sí aparecen, y el total baja', () => {
  const { output } = nodeTest([COVERAGE, '--test-coverage-include-all', ALL_TESTS], KALLPA);
  assert.match(output, /main\.ts\s+\|\s+0\.00/);
  const total = /all files\s+\|\s+([\d.]+)/.exec(output);
  assert.ok(Number(total?.[1]) < 91.54);
});

test('contando solo src/, el total pasa de 91.54 % a 85.79 %', () => {
  const { output } = nodeTest([COVERAGE, '--test-coverage-include=src/**', '--test-coverage-include-all', ALL_TESTS], KALLPA);
  assert.match(output, /all files\s+\|\s+85\.79/);
});

test('un mínimo mayor que la cobertura hace que el comando termine con error', () => {
  const below = nodeTest([COVERAGE, '--test-coverage-lines=95', ALL_TESTS], KALLPA);
  assert.equal(below.status, 1);
  assert.match(below.output, /91\.54%.*does not meet threshold.*95%/);
  assert.equal(nodeTest([COVERAGE, '--test-coverage-lines=90', ALL_TESTS], KALLPA).status, 0);
});
doubles.test.tsDescargar doubles.test.ts
// Las cinco clases de dobles de prueba de Meszaros, sobre Kallpa.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { fixedClock } from '../../kallpa/src/domain/clock.ts';
import type { BookingConfirmation, Notifier } from '../../kallpa/src/notifications/notifier.ts';
import { postBooking, startServer } from './server.ts';

const AT_NINE = { courtId: 1, memberId: 1, date: '2026-03-09', startHour: 9, hours: 1, teamName: 'Los Pumas de Comas' };

test('dummy: listar las canchas no necesita al notificador', async (t) => {
  // Hay que pasarlo para armar el servidor, pero esta prueba no debe usarlo.
  const dummy: Notifier = {
    bookingConfirmed() {
      throw new Error('este doble no debería usarse');
    },
  };
  const server = await startServer({ notifier: dummy });
  t.after(() => server.close());

  const response = await fetch(`${server.url}/api/courts`);

  assert.equal(response.status, 200);
});

test('stub: a las 10:00 la hora de las 9:00 ya pasó', async (t) => {
  const server = await startServer({ clock: fixedClock('2026-03-09T15:00:00Z') });
  t.after(() => server.close());

  const response = await postBooking(server.url, AT_NINE);

  assert.equal(response.status, 422);
  assert.equal(((await response.json()) as { error: { code: string } }).error.code, 'IN_THE_PAST');
});

test('stub: a las 8:00 la hora de las 9:00 todavía se puede reservar', async (t) => {
  const server = await startServer({
    clock: fixedClock('2026-03-09T13:00:00Z'),
    notifier: { bookingConfirmed() {} },
  });
  t.after(() => server.close());

  const response = await postBooking(server.url, AT_NINE);

  assert.equal(response.status, 200);
});

test('t.mock.fn también sirve de stub: la herramienta no decide la clase del doble', async (t) => {
  const now = t.mock.fn(() => new Date('2026-03-09T13:00:00Z'));
  const server = await startServer({ clock: { now }, notifier: { bookingConfirmed() {} } });
  t.after(() => server.close());

  const response = await postBooking(server.url, AT_NINE);

  // No se revisa cuántas veces se leyó el reloj: eso es un detalle interno.
  assert.equal(response.status, 200);
});

test('spy: el log registra cada pedido con su código de respuesta', async (t) => {
  const log = t.mock.fn<(line: string) => void>();
  const server = await startServer({ log, notifier: { bookingConfirmed() {} } });
  t.after(() => server.close());
  const tomorrow = { ...AT_NINE, date: '2026-03-10' };

  await postBooking(server.url, tomorrow);
  await postBooking(server.url, tomorrow);

  assert.deepEqual(
    log.mock.calls.map((call) => call.arguments[0]),
    ['POST /api/bookings 200', 'POST /api/bookings 409'],
  );
});

// Un mock en el sentido de Meszaros: se programa con lo que espera y él mismo
// falla si la llamada no llega o no es la esperada. node:test no trae uno; este
// se escribe a mano para ver la diferencia con el spy.
function mockNotifier(expected: BookingConfirmation): Notifier & { verify(): void } {
  let received = 0;
  return {
    bookingConfirmed(confirmation) {
      assert.deepEqual(confirmation, expected);
      received++;
    },
    verify() {
      assert.equal(received, 1, `se esperaba un aviso y hubo ${received}`);
    },
  };
}

test('mock: el aviso a un socio lleva el precio con descuento', async (t) => {
  const notifier = mockNotifier({
    phone: '912345678',
    memberName: 'Luis Huamán Torres',
    courtName: 'Fulbito 1',
    date: '2026-03-10',
    startHour: 10,
    hours: 1,
    priceCents: 8100,
  });
  const server = await startServer({ notifier });
  t.after(() => server.close());

  await postBooking(server.url, { courtId: 1, memberId: 2, date: '2026-03-10', startHour: 10, hours: 1, teamName: 'Real Barranco' });

  notifier.verify();
});
in-kallpa.test.tsDescargar in-kallpa.test.ts
// Revisa la salida de in-kallpa/run.sh (make la genera en build/logs/).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';

const LOG = readFileSync(new URL('build/logs/in-kallpa.txt', import.meta.url), 'utf8');
const section = (title: string): string => LOG.split(`== ${title}\n`)[1].split('\n== ')[0];

describe('la prueba de interacción frente a la de comportamiento', () => {
  test('en la Kallpa publicada pasan las dos', () => {
    assert.match(section('Kallpa publicada'), /ℹ pass 2\n/);
  });

  test('un refactoring rompe solo la de interacción, y las pruebas de Kallpa siguen en verde', () => {
    const output = section('refactoring: saveBooking pasa a llamarse insertBooking');
    assert.match(output, /ℹ pass 9\nℹ fail 0\n/);
    assert.match(output, /✔ una reserva de dos horas ocupa las dos horas/);
    assert.match(output, /✖ createBooking llama a service.saveBooking/);
    assert.match(output, /insertBooking is not a function/);
  });

  test('un error real rompe solo la de comportamiento', () => {
    const output = section('error: el repositorio guarda siempre 1 hora');
    assert.match(output, /✖ una reserva de dos horas ocupa las dos horas/);
    assert.match(output, /✔ createBooking llama a service.saveBooking/);
  });
});

describe('lo que la cobertura no dice', () => {
  test('una línea ejecutada no es una rama probada: la línea del ?: no queda sin cubrir', () => {
    const output = section('una sola rama');
    assert.match(output, /pricing\.ts \|\s+83\.67 \|\s+75\.00 \|\s+50\.00 \| 39-44 48-49/);
  });

  test('una prueba sin aserciones llega a 100 % en pricing.ts', () => {
    assert.match(section('sin aserciones, pricing.ts correcto'), /pricing\.ts \| 100\.00 \|\s+100\.00 \|\s+100\.00/);
  });

  test('y sigue en 100 % y en verde con el descuento mal', () => {
    const output = section('sin aserciones, descuento de socio de 50 %');
    assert.match(output, /ℹ pass 1\n/);
    assert.match(output, /pricing\.ts \| 100\.00 \|\s+100\.00 \|\s+100\.00/);
  });

  test('las pruebas con aserciones sí lo detectan', () => {
    const output = section('las pruebas de pricing.test.ts con el mismo error');
    assert.match(output, /✖ los socios tienen 10 % de descuento/);
    assert.match(output, /ℹ fail 1/);
  });
});
isolation.test.tsDescargar isolation.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nodeTest } from './run-demo.ts';

const ALONE = '--test-name-pattern=lista las reservas del día';

test('con todas las pruebas, la que depende de otra pasa', () => {
  const { status, output } = nodeTest(['demos/order-dependent.test.ts']);
  assert.equal(status, 0);
  assert.match(output, /ℹ pass 2\n/);
});

test('sola, la misma prueba falla: 0 !== 1', () => {
  const { status, output } = nodeTest([ALONE, 'demos/order-dependent.test.ts']);
  assert.equal(status, 1);
  assert.match(output, /0 !== 1/);
});

test('corriendo cada prueba con su propia base de datos, pasa sola y con todas', () => {
  assert.equal(nodeTest(['demos/order-independent.test.ts']).status, 0);
  assert.equal(nodeTest([ALONE, 'demos/order-independent.test.ts']).status, 0);
});

test('en orden al azar, la dependencia aparece con alguna semilla', () => {
  const statuses = [1, 2, 3, 4, 5, 6, 7, 8].map(
    (seed) => nodeTest([`--test-random-seed=${seed}`, 'demos/order-dependent.test.ts']).status,
  );
  assert.ok(statuses.includes(1), `ninguna semilla la delató: ${statuses}`);
  assert.equal(nodeTest(Array.of('--test-random-seed=1', 'demos/order-independent.test.ts')).status, 0);
});
oracle.test.tsDescargar oracle.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nodeTest } from './run-demo.ts';

test('solo la prueba con el valor de la especificación detecta el error', () => {
  const { status, output } = nodeTest(['demos/oracle.test.ts']);
  assert.equal(status, 1);
  assert.match(output, /✔ copia la fórmula del código/);
  assert.match(output, /✔ copia el valor que devolvió el código/);
  assert.match(output, /✖ usa el valor que dice la especificación/);
  assert.match(output, /ℹ pass 2\n/);
  assert.match(output, /ℹ fail 1\n/);
});
seam.test.tsDescargar seam.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { checkBookingRules } from '../../kallpa/src/domain/booking.ts';
import type { LimaTime } from '../../kallpa/src/domain/clock.ts';
import { hasStarted } from './no-seam.ts';

test('sin costura, el mismo llamado da otro resultado según la hora real', (t) => {
  t.mock.timers.enable({ apis: ['Date'], now: Date.parse('2026-03-09T15:00:00Z') }); // 10:00 en Lima
  assert.equal(hasStarted('2026-03-09', 12), false);
  t.mock.timers.setTime(Date.parse('2026-03-09T18:00:00Z')); // 13:00 en Lima
  assert.equal(hasStarted('2026-03-09', 12), true);
});

test('con costura, "ahora" es un argumento y el resultado no depende de la hora real', () => {
  const now: LimaTime = { date: '2026-03-09', hour: 10, minute: 0 };
  assert.equal(checkBookingRules({ date: '2026-03-09', startHour: 12, hours: 1 }, now), null);
  const later: LimaTime = { date: '2026-03-09', hour: 13, minute: 0 };
  assert.equal(checkBookingRules({ date: '2026-03-09', startHour: 12, hours: 1 }, later)?.code, 'IN_THE_PAST');
});
sizes.test.tsDescargar sizes.test.ts
// Tamaño de las pruebas de Kallpa, según lo que usan: pricing.test.ts no toca
// red ni base de datos; http.test.ts levanta un servidor en 127.0.0.1.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';

const read = (path: string): string => readFileSync(new URL(`../../kallpa/${path}`, import.meta.url), 'utf8');

test('pricing.test.ts solo importa node:test, assert y el dominio', () => {
  const imports = [...read('test/pricing.test.ts').matchAll(/from '([^']+)'/g)].map((match) => match[1]);
  assert.deepEqual(imports, ['node:assert/strict', 'node:test', '../src/domain/pricing.ts']);
});

test('http.test.ts usa el helper que escucha en 127.0.0.1 con SQLite en memoria', () => {
  assert.match(read('test/http.test.ts'), /from '\.\/helpers\.ts'/);
  const helpers = read('test/helpers.ts');
  assert.match(helpers, /listen\(0, '127\.0\.0\.1'/);
  assert.match(helpers, /openDatabase\(':memory:'\)/);
});
tdd.test.tsDescargar tdd.test.ts
// Revisa la historia del ciclo de TDD (make la genera en build/logs/tdd/): cada
// rojo falla por lo esperado y cada verde pasa.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';

const log = (name: string): string => readFileSync(new URL(`build/logs/tdd/${name}`, import.meta.url), 'utf8');
const status = (name: string): number => Number(log(`${name}.status`));

describe('el ciclo de TDD', () => {
  test('el primer rojo sin el módulo falla por el import, no por una aserción', () => {
    assert.equal(status('01-red-missing'), 1);
    assert.match(log('01-red-missing.txt'), /ERR_MODULE_NOT_FOUND/);
    assert.doesNotMatch(log('01-red-missing.txt'), /ERR_ASSERTION/);
  });

  const reds: [string, RegExp][] = [
    ['00-acceptance', /undefined !== 19/],
    ['01-red', /-1 !== null/],
    ['02-red', /null !== 19/],
    ['03-red', /9 !== 20/],
    ['04-red', /19 !== 9/],
  ];
  for (const [name, reason] of reds) {
    test(`${name} falla por la aserción esperada`, () => {
      assert.equal(status(name), 1);
      assert.match(log(`${name}.txt`), /ERR_ASSERTION/);
      assert.match(log(`${name}.txt`), reason);
    });
  }

  for (const name of ['01-green', '02-green', '03-green', '04-green', '04-refactor', '05-acceptance']) {
    test(`${name} pasa`, () => {
      assert.equal(status(name), 0);
    });
  }

  for (const cycle of ['01', '02', '03', '04']) {
    test(`el ciclo ${cycle} pasa tsc y ESLint antes del commit`, () => {
      assert.equal(status(`${cycle}-lint`), 0);
    });
  }

  test('al final, make check pasa con las 45 pruebas', () => {
    assert.equal(status('05-check'), 0);
    assert.match(log('05-check.txt'), /ℹ tests 45\n/);
    assert.match(log('05-check.txt'), /ℹ fail 0\n/);
  });

  test('la historia tiene un commit por ciclo, con los hashes del capítulo', () => {
    assert.deepEqual(log('log.txt').trim().split('\n'), [
      '8b41d83 Kallpa publicada',
      '3c34b43 Sin reservas no hay hora más pedida',
      '6fc5441 Con una reserva, la más pedida es su hora de inicio',
      '6dd8a84 Cuenta cada hora de las reservas largas',
      'b5435ef En un empate gana la hora más temprana',
      '5e3ff9d La caja del día dice la hora más pedida',
    ]);
  });
});
demos/fake-drift.test.tsDescargar demos/fake-drift.test.ts
// Falla a propósito: un fake que dejó de ordenar. El contrato lo delata.
import { bookingStoreContract } from '../booking-store-contract.ts';
import { FakeBookingRepository } from '../fake-booking-repository.ts';

class DriftedFake extends FakeBookingRepository {
  // Devuelve en orden de inserción: la real ordena por hora de inicio.
  #inserted: ReturnType<FakeBookingRepository['listByDate']> = [];

  override insert(row: Parameters<FakeBookingRepository['insert']>[0]): number {
    const id = super.insert(row);
    this.#inserted.push(this.findById(id)!);
    return id;
  }

  override listByCourtAndDate(courtId: number, date: string) {
    return this.#inserted.filter((row) => row.court_id === courtId && row.date === date);
  }
}

bookingStoreContract('fake desactualizado', () => new DriftedFake());
demos/oracle.test.tsDescargar demos/oracle.test.ts
// Tres pruebas sobre el mismo código con error. Solo la última lo detecta.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { formatSoles } from './wrong-format.ts';

test('copia la fórmula del código', () => {
  assert.equal(formatSoles(11250), `S/ ${(11250 / 100).toFixed(1)}`);
});

test('copia el valor que devolvió el código', () => {
  assert.equal(formatSoles(11250), 'S/ 112.5');
});

test('usa el valor que dice la especificación', () => {
  assert.equal(formatSoles(11250), 'S/ 112.50');
});
demos/order-dependent.test.tsDescargar demos/order-dependent.test.ts
// Falla a propósito cuando la segunda prueba corre sola: usa la reserva que dejó
// la primera, en una base de datos compartida.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { BookingRepository } from '../../../kallpa/src/store/booking-repository.ts';
import { openDatabase } from '../../../kallpa/src/store/database.ts';
import { seedCatalog } from '../../../kallpa/src/store/seed.ts';

const db = openDatabase(':memory:');
seedCatalog(db);
const repository = new BookingRepository(db);

test('guarda una reserva', () => {
  const id = repository.insert({
    court_id: 1, member_id: 1, date: '2026-03-10', start_hour: 19, hours: 1,
    team_name: 'Los Pumas de Comas', price_cents: 11250, created_at: '2026-03-09T15:00:00.000Z',
  });
  assert.equal(repository.findById(id)?.start_hour, 19);
});

test('lista las reservas del día', () => {
  assert.equal(repository.listByDate('2026-03-10').length, 1);
});
demos/order-independent.test.tsDescargar demos/order-independent.test.ts
// La misma idea, sin depender del orden: cada prueba prepara lo que necesita
// sobre una base de datos propia.
import assert from 'node:assert/strict';
import { beforeEach, test } from 'node:test';
import { BookingRepository, type NewBookingRow } from '../../../kallpa/src/store/booking-repository.ts';
import { openDatabase } from '../../../kallpa/src/store/database.ts';
import { seedCatalog } from '../../../kallpa/src/store/seed.ts';

const PUMAS: NewBookingRow = {
  court_id: 1, member_id: 1, date: '2026-03-10', start_hour: 19, hours: 1,
  team_name: 'Los Pumas de Comas', price_cents: 11250, created_at: '2026-03-09T15:00:00.000Z',
};

let repository: BookingRepository;

beforeEach(() => {
  const db = openDatabase(':memory:');
  seedCatalog(db);
  repository = new BookingRepository(db);
});

test('guarda una reserva', () => {
  const id = repository.insert(PUMAS);
  assert.equal(repository.findById(id)?.start_hour, 19);
});

test('lista las reservas del día', () => {
  repository.insert(PUMAS);
  assert.equal(repository.listByDate('2026-03-10').length, 1);
});
demos/overfit.test.tsDescargar demos/overfit.test.ts
// Los cuatro ejemplos del ciclo de TDD pasan; un ejemplo nuevo delata la
// implementación hecha a la medida de esos ejemplos.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { busiestHour } from './overfit-busiest-hour.ts';

test('sin reservas no hay hora más pedida', () => {
  assert.equal(busiestHour([]), null);
});

test('con una reserva, la más pedida es su hora de inicio', () => {
  assert.equal(busiestHour([{ startHour: 19, hours: 1 }]), 19);
});

test('una reserva de varias horas cuenta en cada una de sus horas', () => {
  assert.equal(busiestHour([{ startHour: 9, hours: 1 }, { startHour: 19, hours: 2 }, { startHour: 20, hours: 1 }]), 20);
});

test('si hay empate gana la hora más temprana', () => {
  assert.equal(busiestHour([{ startHour: 19, hours: 1 }, { startHour: 9, hours: 1 }]), 9);
});

test('ejemplo nuevo: dos reservas que comparten la hora de las 10:00', () => {
  assert.equal(busiestHour([{ startHour: 9, hours: 2 }, { startHour: 10, hours: 1 }]), 10);
});
in-kallpa/behavior.test.tsDescargar in-kallpa/behavior.test.ts
// Revisa lo que ve quien usa la API: después de reservar dos horas, esas dos
// horas están ocupadas.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getJson, postJson, startTestServer } from './helpers.ts';

test('una reserva de dos horas ocupa las dos horas', async (t) => {
  const server = await startTestServer();
  t.after(() => server.close());

  const created = await postJson(`${server.url}/api/bookings`, {
    courtId: 1, memberId: 1, date: '2026-03-10', startHour: 19, hours: 2, teamName: 'Los Pumas de Comas',
  });
  assert.equal(created.status, 200);

  const { body } = await getJson(`${server.url}/api/courts/1/availability?date=2026-03-10`);
  const taken = body.slots.filter((slot: { available: boolean }) => !slot.available).map((slot: { hour: number }) => slot.hour);
  assert.deepEqual(taken, [19, 20]);
});
in-kallpa/interaction.test.tsDescargar in-kallpa/interaction.test.ts
// Revisa cómo trabaja el handler por dentro: qué método llama y con qué datos.
import assert from 'node:assert/strict';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import { test } from 'node:test';
import { fixedClock } from '../src/domain/clock.ts';
import { createBooking, type AppDependencies } from '../src/http/handlers.ts';

test('createBooking llama a service.saveBooking', async (t) => {
  const saveBooking = t.mock.fn(() => 7);
  const service = {
    findCourt: () => ({ id: 1, name: 'Fulbito 1', sport: 'fulbito' }),
    findMember: () => ({ id: 1, name: 'Rosa', phone: '987654321', plan: 'regular' }),
    bookingsForCourt: () => [],
    saveBooking,
    findBooking: () => ({
      id: 7, court_id: 1, member_id: 1, date: '2026-03-10', start_hour: 19, hours: 2, team_name: 'Los Pumas de Comas',
      price_cents: 22500, status: 'confirmed', created_at: '2026-03-09T15:00:00.000Z', cancelled_at: null,
    }),
  };
  const deps = {
    service,
    clock: fixedClock('2026-03-09T15:00:00Z'),
    notifier: { bookingConfirmed() {} },
  } as unknown as AppDependencies;
  const body = { courtId: 1, memberId: 1, date: '2026-03-10', startHour: 19, hours: 2, teamName: 'Los Pumas de Comas' };
  const req = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage;
  const res = { writeHead() {}, end() {} } as unknown as ServerResponse;

  await createBooking(deps)({ req, res, url: new URL('http://localhost/api/bookings'), params: {} });

  assert.equal(saveBooking.mock.callCount(), 1);
});
in-kallpa/no-assert.test.tsDescargar in-kallpa/no-assert.test.ts
// Pasa por todas las líneas y ramas de pricing.ts sin revisar nada.
import { test } from 'node:test';
import { formatSoles, quotePriceCents } from '../src/domain/pricing.ts';

test('precios', () => {
  quotePriceCents({ sport: 'fulbito', date: '2026-03-10', startHour: 10, hours: 1, plan: 'regular' });
  quotePriceCents({ sport: 'fulbito', date: '2026-03-10', startHour: 20, hours: 1, plan: 'socio' });
  formatSoles(11250);
});
in-kallpa/one-branch.test.tsDescargar in-kallpa/one-branch.test.ts
// La línea del `?:` de hourlyRateCents se ejecuta, pero solo por una rama.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { hourlyRateCents } from '../src/domain/pricing.ts';

test('una hora normal de fulbito cuesta S/ 90', () => {
  assert.equal(hourlyRateCents('fulbito', '2026-03-10', 10), 9000);
});
tdd/steps/00-acceptance/test/busiest-hour-http.test.tsDescargar tdd/steps/00-acceptance/test/busiest-hour-http.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getJson, postJson, startTestServer } from './helpers.ts';

test('la caja del día dice cuál fue la hora más pedida, sin contar las canceladas', async (t) => {
  const server = await startTestServer();
  t.after(() => server.close());
  const day = { date: '2026-03-10', memberId: 1, teamName: 'Los Pumas de Comas' };

  // Dado que el martes hay dos reservas a las 19:00, una a las 8:00 y otra a las 8:00 que se canceló
  await postJson(`${server.url}/api/bookings`, { ...day, courtId: 1, startHour: 19 });
  await postJson(`${server.url}/api/bookings`, { ...day, courtId: 2, startHour: 19 });
  await postJson(`${server.url}/api/bookings`, { ...day, courtId: 3, startHour: 8 });
  const cancelled = await postJson(`${server.url}/api/bookings`, { ...day, courtId: 1, startHour: 8 });
  await postJson(`${server.url}/api/bookings/${cancelled.body.booking.id}/cancel`);

  // Cuando la administración pide la caja de ese día
  const { status, body } = await getJson(`${server.url}/api/reports/daily?date=2026-03-10`);

  // Entonces la hora más pedida es las 19:00
  assert.equal(status, 200);
  assert.equal(body.busiestHour, 19);
});
tdd/steps/01-red-missing/test/busiest-hour.test.tsDescargar tdd/steps/01-red-missing/test/busiest-hour.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { busiestHour } from '../src/domain/busiest-hour.ts';

test('sin reservas no hay hora más pedida', () => {
  assert.equal(busiestHour([]), null);
});
tdd/steps/02-red/test/busiest-hour.test.tsDescargar tdd/steps/02-red/test/busiest-hour.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { busiestHour } from '../src/domain/busiest-hour.ts';

test('sin reservas no hay hora más pedida', () => {
  assert.equal(busiestHour([]), null);
});

test('con una reserva, la más pedida es su hora de inicio', () => {
  assert.equal(busiestHour([{ startHour: 19, hours: 1 }]), 19);
});
tdd/steps/03-red/test/busiest-hour.test.tsDescargar tdd/steps/03-red/test/busiest-hour.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { busiestHour } from '../src/domain/busiest-hour.ts';

test('sin reservas no hay hora más pedida', () => {
  assert.equal(busiestHour([]), null);
});

test('con una reserva, la más pedida es su hora de inicio', () => {
  assert.equal(busiestHour([{ startHour: 19, hours: 1 }]), 19);
});

test('una reserva de varias horas cuenta en cada una de sus horas', () => {
  const bookings = [
    { startHour: 9, hours: 1 },
    { startHour: 19, hours: 2 },
    { startHour: 20, hours: 1 },
  ];
  assert.equal(busiestHour(bookings), 20);
});
tdd/steps/04-red/test/busiest-hour.test.tsDescargar tdd/steps/04-red/test/busiest-hour.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { busiestHour } from '../src/domain/busiest-hour.ts';

test('sin reservas no hay hora más pedida', () => {
  assert.equal(busiestHour([]), null);
});

test('con una reserva, la más pedida es su hora de inicio', () => {
  assert.equal(busiestHour([{ startHour: 19, hours: 1 }]), 19);
});

test('una reserva de varias horas cuenta en cada una de sus horas', () => {
  const bookings = [
    { startHour: 9, hours: 1 },
    { startHour: 19, hours: 2 },
    { startHour: 20, hours: 1 },
  ];
  assert.equal(busiestHour(bookings), 20);
});

test('si hay empate gana la hora más temprana', () => {
  assert.equal(busiestHour([{ startHour: 19, hours: 1 }, { startHour: 9, hours: 1 }]), 9);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 9: pruebas unitarias, dobles de prueba, cobertura, TDD y BDD. Se usa
# dentro del contenedor del curso.
#
#   make           corre las demostraciones que necesitan una copia de Kallpa
#                  (in-kallpa/ y el ciclo de TDD); la salida queda en build/logs/
#   make test      revisa los tipos (tsc), corre ESLint, prepara lo anterior y
#                  corre las pruebas (*.test.ts), que comprueban lo que dice el
#                  apunte
#   make cobertura la tabla de cobertura de Kallpa, sin tocar la original
#   make tdd       repite el ciclo de TDD del capítulo en build/tdd
#   make clean     borra build/
#
# Las demostraciones leen la Kallpa del curso (../../kallpa) sin modificarla.
# Los archivos de demos/ fallan a propósito: se corren desde *.test.ts.

KALLPA ?= ../../kallpa
export KALLPA

.PHONY: all test cobertura tdd in-kallpa clean

all: tdd in-kallpa

test: all
	tsc -p .
	eslint .
	node --test "*.test.ts"

cobertura:
	cd $(KALLPA) && node --test --experimental-test-coverage "test/**/*.test.ts"

tdd:
	bash tdd/replay.sh

in-kallpa:
	mkdir -p build/logs
	bash in-kallpa/run.sh > build/logs/in-kallpa.txt 2>&1

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-09",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": [
      "es2024"
    ],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": [
      "node"
    ],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": [
    "*.ts",
    "demos"
  ],
  "exclude": [
    "build",
    "solution",
    "in-kallpa",
    "tdd"
  ]
}