CULTIVA IA · Skill: Desarrollo Guiado por Pruebas (TDD)

Sesión TDD completa · Cliente: LeadFlow SaaS · Módulo: PlanCart

● RED ● GREEN ● REFACTOR TypeScript + Vitest 6 ciclos
🗂 Fase 0 — Planning Diseño de interfaz y lista de comportamientos a testear
Interfaz pública acordada
src/billing/PlanCart.ts — tipos públicos typescript
// ─── Tipos del dominio (acordados antes de escribir tests) ───────────────────

type Plan = {
  id:    string;
  name:  string;
  price: number;  // EUR cents
};

type Discount = {
  code:       string;
  type:       'percent' | 'fixed';
  value:      number;   // % o EUR cents
  expiresAt:  Date | null;
};

type Money = number;  // always EUR cents, never float math

type CartError  = 'EMPTY_CART';
type DiscountError = 'INVALID_CODE' | 'EXPIRED_CODE';

type Result<T, E> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

// ─── Interfaz pública ────────────────────────────────────────────────────────
interface IPlanCart {
  add(plan: Plan): void;
  applyDiscount(code: string, catalog: Discount[]): Result<Discount, DiscountError>;
  getTotal(): Money;
  checkout(): Result<Order, CartError>;
}
Comportamientos priorizados
1
C1 — Añadir plan y recuperar total
Tracer bullet — primer ciclo rojo→verde que prueba el camino extremo a extremo
🔴 RED Escribir el test que falla
src/billing/__tests__/PlanCart.test.ts vitest
import { describe, it, expect } from 'vitest';
import { PlanCart } from '../PlanCart';

const starter: Plan = { id: 'starter', name: 'Starter', price: 4900 }; // 49 €

describe('PlanCart', () => {
  it('usuario puede añadir un plan y getTotal devuelve su precio', () => {
    const cart = new PlanCart();
    cart.add(starter);
    expect(cart.getTotal()).toBe(4900);
  });
});
❌ FAIL src/billing/__tests__/PlanCart.test.ts PlanCart › usuario puede añadir un plan y getTotal devuelve su precio Error: Cannot find module '../PlanCart' from 'PlanCart.test.ts'
🟢 GREEN Código mínimo para pasar el test
src/billing/PlanCart.ts typescript
export class PlanCart {
  private plan: Plan | null = null;

  add(plan: Plan): void {
    this.plan = plan;
  }

  getTotal(): Money {
    return this.plan?.price ?? 0;
  }
}
✓ PASS src/billing/__tests__/PlanCart.test.ts ✓ PlanCart › usuario puede añadir un plan y getTotal devuelve su precio (3ms)
2
C2 — Cambiar de plan reemplaza el anterior
Verifica que el carrito acepta solo un plan activo
🔴 RED Nuevo test que falla
src/billing/__tests__/PlanCart.test.ts — C2 vitest
const pro: Plan = { id: 'pro', name: 'Pro', price: 9900 }; // 99 €

it('cambiar de plan actualiza el total al precio del nuevo plan', () => {
  const cart = new PlanCart();
  cart.add(starter);
  cart.add(pro);                     // reemplaza starter
  expect(cart.getTotal()).toBe(9900);
});
❌ FAIL — Expected: 9900 · Received: 9900 (El test pasa porque add() ya sobreescribe — pero verifiquemos explícitamente que no acumula)
Nota TDD: El test pasó con el código existente — buen signo. El comportamiento de "reemplazar" ya era correcto gracias al diseño inicial simple. No añadimos código especulativo.
✓ PASS PlanCart › cambiar de plan actualiza el total al precio del nuevo plan (1ms)
3
C3 — Cupón de descuento porcentual
Aplica 20% de descuento sobre el precio del plan
🔴 RED
src/billing/__tests__/PlanCart.test.ts — C3 vitest
const catalog: Discount[] = [
  {
    code: 'CULTIVA20',
    type: 'percent',
    value: 20,
    expiresAt: new Date('2099-12-31'),
  }
];

it('aplicar cupón porcentual reduce el total en ese porcentaje', () => {
  const cart = new PlanCart();
  cart.add(pro); // 99 €

  const result = cart.applyDiscount('CULTIVA20', catalog);
  expect(result.ok).toBe(true);
  expect(cart.getTotal()).toBe(7920); // 9900 - 20% = 7920 cents
});
❌ FAIL — TypeError: cart.applyDiscount is not a function
🟢 GREEN
src/billing/PlanCart.ts — añadir applyDiscount typescript
export class PlanCart {
  private plan: Plan | null = null;
  private discount: Discount | null = null;

  add(plan: Plan): void {
    this.plan = plan;
  }

  applyDiscount(code: string, catalog: Discount[]): Result<Discount, DiscountError> {
    const d = catalog.find(d => d.code === code);
    if (!d) return { ok: false, error: 'INVALID_CODE' };
    this.discount = d;
    return { ok: true, value: d };
  }

  getTotal(): Money {
    if (!this.plan) return 0;
    if (!this.discount) return this.plan.price;

    if (this.discount.type === 'percent') {
      return Math.round(this.plan.price * (1 - this.discount.value / 100));
    }
    return this.plan.price;  // fixed: siguiente ciclo
  }
}
✓ PASS PlanCart › aplicar cupón porcentual reduce el total en ese porcentaje (2ms)
4
C4 — Cupón de descuento fijo
Descuento de importe fijo en céntimos de euro
🔴 RED
src/billing/__tests__/PlanCart.test.ts — C4 vitest
const catalogConFijo: Discount[] = [
  { code: 'BIENVENIDA10', type: 'fixed', value: 1000, expiresAt: null }
];

it('aplicar cupón fijo resta el importe exacto del total', () => {
  const cart = new PlanCart();
  cart.add(starter); // 49 €

  cart.applyDiscount('BIENVENIDA10', catalogConFijo);
  expect(cart.getTotal()).toBe(3900); // 4900 - 1000 cents = 3900
});
❌ FAIL — Expected: 3900 · Received: 4900
🟢 GREEN
src/billing/PlanCart.ts — getTotal actualizado typescript
  getTotal(): Money {
    if (!this.plan) return 0;
    if (!this.discount) return this.plan.price;

    if (this.discount.type === 'percent') {
      return Math.round(this.plan.price * (1 - this.discount.value / 100));
    }
    return Math.max(0, this.plan.price - this.discount.value); // ← fijo, nunca negativo
  }
✓ PASS PlanCart › aplicar cupón fijo resta el importe exacto del total (1ms)
5
C5 — Cupón expirado rechazado
El sistema protege contra cupones fuera de fecha
🔴 RED
src/billing/__tests__/PlanCart.test.ts — C5 vitest
const catalogExpirado: Discount[] = [
  {
    code: 'VERANO24', type: 'percent', value: 15,
    expiresAt: new Date('2024-09-01'),   // expiró en 2024
  }
];

it('cupón expirado devuelve error EXPIRED_CODE y no modifica el total', () => {
  const cart = new PlanCart();
  cart.add(pro);

  const result = cart.applyDiscount('VERANO24', catalogExpirado);
  expect(result.ok).toBe(false);
  expect((result as any).error).toBe('EXPIRED_CODE');
  expect(cart.getTotal()).toBe(9900); // sin cambios
});
❌ FAIL — Expected: false · Received: true (el código no verifica expiración)
🟢 GREEN
src/billing/PlanCart.ts — applyDiscount actualizado typescript
  applyDiscount(code: string, catalog: Discount[], now = new Date()): Result<Discount, DiscountError> {
    const d = catalog.find(d => d.code === code);
    if (!d) return { ok: false, error: 'INVALID_CODE' };

    if (d.expiresAt !== null && d.expiresAt < now) {
      return { ok: false, error: 'EXPIRED_CODE' };
    }

    this.discount = d;
    return { ok: true, value: d };
  }
✓ PASS PlanCart › cupón expirado devuelve error EXPIRED_CODE y no modifica el total (2ms)
6
C6 — Checkout con carrito vacío
Guardia de validación antes de crear una orden
🔴 RED
src/billing/__tests__/PlanCart.test.ts — C6 vitest
it('checkout con carrito vacío devuelve error EMPTY_CART', () => {
  const cart = new PlanCart();
  const result = cart.checkout();
  expect(result.ok).toBe(false);
  expect((result as any).error).toBe('EMPTY_CART');
});

it('checkout con plan activo devuelve orden con total correcto', () => {
  const cart = new PlanCart();
  cart.add(starter);
  const result = cart.checkout();
  expect(result.ok).toBe(true);
  expect((result as any).value.total).toBe(4900);
});
❌ FAIL — TypeError: cart.checkout is not a function
🟢 GREEN
src/billing/PlanCart.ts — checkout añadido typescript
  checkout(): Result<Order, CartError> {
    if (!this.plan) return { ok: false, error: 'EMPTY_CART' };
    return {
      ok: true,
      value: {
        planId:    this.plan.id,
        planName:  this.plan.name,
        discount:  this.discount?.code ?? null,
        total:     this.getTotal(),
        createdAt: new Date(),
      }
    };
  }
✓ PASS PlanCart › checkout con carrito vacío devuelve error EMPTY_CART (1ms) ✓ PASS PlanCart › checkout con plan activo devuelve orden con total correcto (1ms)
🟣 REFACTOR Todos los tests en verde — ahora limpiar sin cambiar comportamiento
Candidatos de refactor identificados
src/billing/PlanCart.ts — versión final refactorizada typescript
import type { Plan, Discount, Money, Result, Order, CartError, DiscountError } from './types';

function applyDiscount(price: Money, d: Discount): Money {
  if (d.type === 'percent') return Math.round(price * (1 - d.value / 100));
  return                      Math.max(0, price - d.value);
}

function isExpired(d: Discount, now: Date): boolean {
  return d.expiresAt !== null && d.expiresAt < now;
}

export class PlanCart {
  private plan: Plan     | null = null;
  private discount: Discount | null = null;

  constructor(private readonly now = new Date()) {}

  add(plan: Plan): void { this.plan = plan; }

  applyDiscount(code: string, catalog: Discount[]): Result<Discount, DiscountError> {
    const d = catalog.find(d => d.code === code);
    if (!d)               return { ok: false, error: 'INVALID_CODE'  };
    if (isExpired(d, this.now)) return { ok: false, error: 'EXPIRED_CODE' };
    this.discount = d;
    return { ok: true, value: d };
  }

  getTotal(): Money {
    if (!this.plan) return 0;
    return this.discount
      ? applyDiscount(this.plan.price, this.discount)
      : this.plan.price;
  }

  checkout(): Result<Order, CartError> {
    if (!this.plan) return { ok: false, error: 'EMPTY_CART' };
    return { ok: true, value: {
      planId:   this.plan.id,
      planName: this.plan.name,
      discount: this.discount?.code ?? null,
      total:    this.getTotal(),
      createdAt: this.now,
    }};
  }
}
✓ PASS src/billing/__tests__/PlanCart.test.ts (6 tests) ✓ usuario puede añadir un plan y getTotal devuelve su precio 3ms ✓ cambiar de plan actualiza el total al precio del nuevo plan 1ms ✓ aplicar cupón porcentual reduce el total en ese porcentaje 2ms ✓ aplicar cupón fijo resta el importe exacto del total 1ms ✓ cupón expirado devuelve error EXPIRED_CODE y no modifica el total 2ms ✓ checkout con carrito vacío devuelve error EMPTY_CART 1ms ✓ checkout con plan activo devuelve orden con total correcto 1ms Duration 47ms (transform 23ms, setup 0ms, collect 8ms, tests 10ms)

Resumen de la sesión TDD
# Comportamiento Método de test Resultado Ciclos RED→GREEN
C1 Añadir plan + getTotal add() / getTotal() ✓ PASS 1
C2 Cambio de plan reemplaza anterior add() x2 ✓ PASS 0 (ya verde)
C3 Descuento porcentual applyDiscount() + getTotal() ✓ PASS 1
C4 Descuento fijo applyDiscount() + getTotal() ✓ PASS 1
C5 Cupón expirado rechazado applyDiscount() → error ✓ PASS 1
C6 Checkout carrito vacío checkout() → error ✓ PASS 1
Checklist TDD — cumplimiento
Anti-patrón evitado — Horizontal Slicing: No se escribieron todos los tests a la vez antes de comenzar la implementación. Cada ciclo rojo→verde reveló información que guió el diseño del siguiente test. Intentar escribir C3 antes de implementar C1 habría resultado en tests acoplados a una estructura imaginada.
Principio de módulo profundo aplicado: PlanCart expone 4 métodos (interfaz pequeña) pero encapsula toda la lógica de descuentos, validación de expiración y construcción de la orden (implementación profunda). Los consumidores del módulo no necesitan conocer cómo se calcula el descuento.
Flujo del ciclo TDD completado
🗂 Planning
Interface
🔴 RED × 5
tests escritos
🟢 GREEN × 5
impl mínima
🟣 REFACTOR
limpieza
7 tests
en verde
🚀 PlanCart
production-ready