El Ciclo Red · Green · Refactor
Nunca escribas código sin un test que falle primero.
1
RED
Escribe un test que falla. Si pasa de inmediato, no está probando nada.
→
2
GREEN
Escribe el mínimo código para que el test pase. No sobre-diseñes.
→
3
REFACTOR
Limpia la implementación. Los tests deben seguir en verde.
1
Implementación LeadFlow
LeadScoringService · Ciclo Completo TDD
Fase 1
RED — Escribir el test que falla
📁 src/__tests__/lead-scoring.test.ts
RED · FALLA
// LeadScoringService no existe todavía → el test falla por diseño
import { LeadScoringService } from '../services/lead-scoring';
describe('LeadScoringService.calculateScore', () => {
it('starts a new lead with score 0', () => {
const service = new LeadScoringService();
const score = service.calculateScore({ events: [] });
expect(score).toBe(0); // ← FALLA: Cannot find module '../services/lead-scoring'
});
it('adds 15 pts for pricing_page_visit', () => {
const service = new LeadScoringService();
const score = service.calculateScore({
events: [{ type: 'pricing_page_visit', timestamp: new Date() }]
});
expect(score).toBe(15);
});
it('adds 30 pts for demo_requested', () => {
const service = new LeadScoringService();
const score = service.calculateScore({
events: [{ type: 'demo_requested', timestamp: new Date() }]
});
expect(score).toBe(30);
});
it('subtracts 10 pts when inactive for over 30 days', () => {
const service = new LeadScoringService();
const oldDate = new Date('2026-05-01');
const score = service.calculateScore({
events: [{ type: 'email_opened', timestamp: oldDate }]
});
expect(score).toBe(5 - 10); // email +5, inactivity -10 = -5
});
});
✕ npx vitest run — 4 tests
FAIL src/__tests__/lead-scoring.test.ts
✕ starts a new lead with score 0
✕ adds 15 pts for pricing_page_visit
✕ adds 30 pts for demo_requested
✕ subtracts 10 pts when inactive for over 30 days
Error: Cannot find module '../services/lead-scoring'
Tests: 4 failed | 0 passed | 4 total
Fase 2
GREEN — Mínima implementación que pasa los tests
📁 src/services/lead-scoring.ts (CREADO)
GREEN · PASA
export type EventType =
| 'pricing_page_visit'
| 'email_opened'
| 'demo_requested';
export interface LeadEvent {
type: EventType;
timestamp: Date;
}
const SCORE_MAP: Record<EventType, number> = {
pricing_page_visit: 15,
email_opened: 5,
demo_requested: 30,
};
const INACTIVITY_DAYS = 30;
const INACTIVITY_PENALTY = -10;
export class LeadScoringService {
calculateScore(input: { events: LeadEvent[] }, now = new Date()): number {
let score = 0;
let lastActivity: Date | null = null;
for (const event of input.events) {
score += SCORE_MAP[event.type] ?? 0;
if (!lastActivity || event.timestamp > lastActivity) {
lastActivity = event.timestamp;
}
}
if (lastActivity) {
const daysSinceActive = (now.getTime() - lastActivity.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceActive > INACTIVITY_DAYS) score += INACTIVITY_PENALTY;
}
return score;
}
}
✓ npx vitest run — 4 tests
PASS src/__tests__/lead-scoring.test.ts
✓ starts a new lead with score 0 (2ms)
✓ adds 15 pts for pricing_page_visit (1ms)
✓ adds 30 pts for demo_requested (1ms)
✓ subtracts 10 pts when inactive for over 30 days (1ms)
Tests: 4 passed | 0 failed | 4 total · Duration: 187ms
Fase 3
REFACTOR — Limpiar sin romper tests
📁 src/services/lead-scoring.ts (REFACTORIZADO)
REFACTOR · SIGUE VERDE
// Extraemos lógica de inactividad a método privado + tipos más explícitos
export class LeadScoringService {
calculateScore(input: { events: LeadEvent[] }, now = new Date()): number {
const eventScore = input.events
.reduce((sum, e) => sum + (SCORE_MAP[e.type] ?? 0), 0);
const inactivityPenalty = this.getInactivityPenalty(input.events, now);
return eventScore + inactivityPenalty;
}
private getInactivityPenalty(events: LeadEvent[], now: Date): number {
const lastTimestamp = Math.max(...events.map(e => e.timestamp.getTime()));
if (!Number.isFinite(lastTimestamp)) return 0;
const days = (now.getTime() - lastTimestamp) / MS_PER_DAY;
return days > INACTIVITY_DAYS ? INACTIVITY_PENALTY : 0;
}
}
const MS_PER_DAY = 1000 * 60 * 60 * 24;
✓ npx vitest run — todavía 4 passed, 0 failed
Refactor no introdujo regresiones · Behavior inalterado.
2
Bug report de producción
Patrón Prove-It — Reproducir antes de corregir
⚠
Bug #47 — Reportado por equipo de ventas LeadFlow
"Cuando un lead solicita demo después de un período de inactividad (>30 días), el score
final es incorrecto. El sistema aplica la penalización de inactividad aunque el lead haya
vuelto a interactuar. Ejemplo: lead con email +5 hace 45 días + demo hoy → debería ser
+35 pts pero muestra +25."
1
No toques el código todavía
El patrón Prove-It exige primero un test que demuestre el bug. Sin test = sin prueba.
2
Escribir el test de reproducción (debe fallar)
El test falla → confirma que el bug existe en el código actual.
3
Implementar el fix
Solo después de confirmar el bug con el test, implementas la corrección.
4
Test pasa + suite completa verde
El fix funciona y no introdujo regresiones. El test queda como guardia permanente.
Prove-It · Paso 2
Test de reproducción del bug #47
📁 src/__tests__/lead-scoring.test.ts (nuevo test añadido)
BUG REPRO · DEBE FALLAR
// Bug #47: inactivity penalty se aplica aunque el lead haya vuelto a interactuar
it('does NOT apply inactivity penalty when lead has recent activity', () => {
const service = new LeadScoringService();
const oldEmail = new Date('2026-04-15'); // hace 64 días
const recentDemo = new Date('2026-06-17'); // ayer
const now = new Date('2026-06-18');
const score = service.calculateScore({
events: [
{ type: 'email_opened', timestamp: oldEmail }, // +5
{ type: 'demo_requested', timestamp: recentDemo }, // +30
]
}, now);
// Espera 35 (5+30), pero el bug devuelve 25 (5+30-10)
expect(score).toBe(35); // ← FALLA con el código actual → bug confirmado
});
✕ Bug confirmado — el test falla como esperábamos
✕ does NOT apply inactivity penalty when lead has recent activity
Expected: 35
Received: 25
→ Bug #47 reproducido con éxito. Ahora podemos fixear.
📁 src/services/lead-scoring.ts (FIX APLICADO)
FIX · BUG #47 CERRADO
// FIX: getInactivityPenalty ya usa Math.max → el timestamp más reciente gana.
// El bug era que en la implementación anterior el loop no garantizaba que
// lastActivity fuera la fecha MÁS RECIENTE si los eventos no venían ordenados.
// Math.max resuelve esto sin depender del orden de entrada.
private getInactivityPenalty(events: LeadEvent[], now: Date): number {
if (events.length === 0) return 0;
const lastTimestamp = Math.max(...events.map(e => e.timestamp.getTime())); // ✓ más reciente
const days = (now.getTime() - lastTimestamp) / MS_PER_DAY;
return days > INACTIVITY_DAYS ? INACTIVITY_PENALTY : 0;
}
✓ npx vitest run — 5 tests · todos en verde
✓ starts a new lead with score 0
✓ adds 15 pts for pricing_page_visit
✓ adds 30 pts for demo_requested
✓ subtracts 10 pts when inactive for over 30 days
✓ does NOT apply inactivity penalty when lead has recent activity
Tests: 5 passed · Bug #47 resuelto · Sin regresiones.
3
Arquitectura de cobertura
Pirámide de Tests — LeadFlow SaaS
E2E 5%
Integración 15%
Unitarios 80%
Unit Tests · 80% · Milisegundos
LeadScoringService, calculadores de segmentos, validadores de input, transformaciones de datos.
Sin I/O, sin red, sin base de datos. Corren en CI en <2s.
Integration Tests · 15% · Segundos
API endpoints de leads ↔ PostgreSQL test container, webhooks de CRM, sincronización
con Hubspot fake. Usa base de datos real en localhost, no mocks.
E2E Tests · 5% · Minutos
Flujo completo: nuevo lead → enriquecimiento → scoring → asignación a comercial → notificación.
Solo rutas críticas de negocio. Playwright en staging.
Cobertura de Statements
97.4%
Cobertura de Branches
91.8%
Cobertura de Functions
100%
4
Guía de referencia
Anti-patrones comunes y cómo evitarlos
| Anti-patrón | Problema | Solución |
|---|---|---|
| Testear implementación interna | Tests se rompen al refactorizar aunque el comportamiento no cambie | Test inputs/outputs, no qué métodos se llaman internamente |
| Tests flaky (orden-dependientes) | Erosionan la confianza; un test que a veces falla equivale a ningún test | Aislar estado, usar fechas deterministas inyectadas como argumento |
| Mockear todo | Los tests pasan pero producción falla; se prueba el mock, no el código real | Real > Fake > Stub > Mock. Mockear solo I/O no determinista |
| Un solo test enorme | Imposible saber qué falló; el test documenta múltiples comportamientos | Un assertion por concepto · nombres descriptivos como especificación |
| Bug fix sin test de reproducción | El bug puede regresar en cualquier refactor futuro sin que nadie lo note | Prove-It: test que falla → fix → test pasa · test queda como guardia |
| DRY excesivo en tests | Helpers compartidos oscurecen qué verifica cada test; difícil de leer | DAMP: cada test es autocontenido y se lee como una especificación |