LeadFlow AI
Suite de Tests TypeScript / Vitest
✓ 47 tests passing Vitest 2.1 TypeScript 5.4

Patrones de Testing JavaScript/TypeScript

Suite completa de tests unitarios, de integración y de componentes React aplicada al motor de scoring de leads de LeadFlow AI — de 12% a 83% de cobertura en 6 semanas.

Coverage Global
83%
Líneas
87%
Funciones
91%
Branches
80%
Tests totales
47

⚡ Resultados — Vitest

Ejecución completa de la suite en 1.24 s. Todos los módulos del motor de scoring superan el umbral de 80%.

✓ 47 passed 47 total Duration: 1.24 s Vitest v2.1.8
▶ LeadScorer — tests unitarios (14)
✓debe puntuar 100 cuando todos los factores son óptimos2ms
✓debe puntuar 0 cuando no hay datos firmográficos1ms
✓debe aplicar penalización por email genérico (gmail/hotmail)1ms
✓debe ponderar tamaño empresa: 1–10 → 0.3, 11–50 → 0.6, 51+ → 1.02ms
✓debe lanzar LeadScorerError si el lead no tiene email1ms
✓debe sumar puntos por interacciones (pageview, demo, descarga)2ms
✓debe normalizar score entre 0–100 aunque suma supere 1001ms
✓debe devolver tier HOT cuando score ≥ 751ms
✓debe devolver tier WARM cuando 40 ≤ score < 751ms
✓debe devolver tier COLD cuando score < 401ms
✓debe cachear resultado por 5 minutos3ms
✓debe invalidar caché al actualizar datos del lead2ms
✓debe funcionar con leads sin interacciones previas1ms
✓debe ser idempotente para el mismo input1ms
▶ CampaignService — tests unitarios con DI (11)
✓create() debe crear campaña y persistir en repositorio2ms
✓create() debe lanzar error si nombre duplicado1ms
✓pause() debe cambiar status a PAUSED1ms
✓pause() debe lanzar error si campaña no existe1ms
✓activate() debe cambiar status de PAUSED a ACTIVE1ms
✓getMetrics() debe agregar leads por tier correctamente3ms
✓getMetrics() debe retornar tasas de conversión redondeadas a 2 decimales2ms
✓debe emitir evento campaign:created al crear2ms
✓debe emitir evento campaign:paused al pausar1ms
✓delete() solo permitido si status es DRAFT1ms
✓delete() debe lanzar CampaignActiveError si status es ACTIVE1ms
▶ EmailNotificationService — mocking SendGrid (8)
✓sendLeadAlert() debe llamar a sgMail.send con template correcto4ms
✓sendLeadAlert() debe incluir score y tier en las variables del template2ms
✓sendWeeklyDigest() debe agrupar leads HOT primero3ms
✓debe reintentar 3 veces ante error 429 de SendGrid12ms
✓debe lanzar EmailDeliveryError tras 3 intentos fallidos11ms
✓no debe enviar email si lead está en lista de supresión1ms
✓debe loggear error sin lanzar excepción en modo graceful2ms
✓debe limpiar datos PII antes de loggear2ms
▶ useLeadScore hook — React Testing Library (9)
✓debe retornar { score: null, loading: true } en estado inicial8ms
✓debe retornar score tras fetch exitoso15ms
✓debe retornar error si API falla con 4xx10ms
✓debe re-fetch al llamar a refresh()18ms
✓debe cancelar fetch al desmontar componente (AbortController)12ms
✓debe usar caché local si el score fue calculado en los últimos 5 min6ms
✓debe exponer tier derivado del score7ms
✓debe actualizar score optimistamente antes de confirmar API14ms
✓debe rollback si la API rechaza el score optimista16ms
▶ Factories con @faker-js/faker (5)
✓createLeadFixture() debe generar lead con email corporativo por defecto2ms
✓createLeadFixture() debe aceptar overrides parciales1ms
✓createCampaignFixture() debe generar fechas coherentes (end > start)1ms
✓createInteractionFixture() debe generar tipos válidos del enum1ms
✓leadBatchFixture(n) debe generar n leads únicos sin colisiones de id3ms

🧪 Patrón 1 — Test de Clase con Lógica de Negocio

Tests unitarios de LeadScorer: clase que calcula puntuación 0–100 con factores firmográficos, interacciones y penalizaciones. Patrón AAA (Arrange / Act / Assert).

lead-scorer.ts
lead-scorer.test.ts
// src/scoring/lead-scorer.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { LeadScorer, LeadScorerError } from './lead-scorer'
import { createLeadFixture, createInteractionFixture } from '../tests/fixtures'

describe('LeadScorer', () => {
  let scorer: LeadScorer

  beforeEach(() => {
    scorer = new LeadScorer()
  })

  describe('score()', () => {
    it('debe puntuar 100 cuando todos los factores son óptimos', () => {
      // Arrange
      const lead = createLeadFixture({
        email: 'ceo@acme-corp.com',
        company: { size: 150, industry: 'SaaS', revenue: 5_000_000 },
      })
      const interactions = [
        createInteractionFixture({ type: 'demo_requested' }),
        createInteractionFixture({ type: 'pricing_visit' }),
        createInteractionFixture({ type: 'whitepaper_download' }),
      ]

      // Act
      const result = scorer.score(lead, interactions)

      // Assert
      expect(result.score).toBe(100)
      expect(result.tier).toBe('HOT')
    })

    it('debe aplicar penalización por email genérico', () => {
      // Arrange
      const lead = createLeadFixture({ email: 'juan@gmail.com' })

      // Act
      const result = scorer.score(lead, [])

      // Assert
      expect(result.penalties).toContain('generic_email')
      expect(result.score).toBeLessThan(40)
    })

    it('debe lanzar LeadScorerError si el lead no tiene email', () => {
      // Arrange
      const lead = createLeadFixture({ email: undefined })

      // Act + Assert
      expect(() => scorer.score(lead, []))
        .toThrow(LeadScorerError)
    })

    it('debe normalizar score entre 0–100 aunque suma supere 100', () => {
      // Arrange — 20 interacciones de alto valor
      const lead = createLeadFixture({ email: 'cto@startup.io' })
      const interactions = Array.from({ length: 20 }, () =>
        createInteractionFixture({ type: 'demo_requested' })
      )

      // Act
      const result = scorer.score(lead, interactions)

      // Assert
      expect(result.score).toBeGreaterThanOrEqual(0)
      expect(result.score).toBeLessThanOrEqual(100)
    })
  })
})

🔌 Patrón 2 — Inyección de Dependencias y Mocks

Tests de CampaignService inyectando un repositorio mock en lugar de la base de datos real. Permite tests rápidos y aislados sin conexión a Postgres.

campaign.service.test.ts
// src/campaigns/campaign.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { CampaignService, CampaignActiveError } from './campaign.service'
import type { ICampaignRepository } from './campaign.repository.interface'
import { createCampaignFixture } from '../tests/fixtures'

describe('CampaignService', () => {
  let service: CampaignService
  let repo: ICampaignRepository

  beforeEach(() => {
    // Mock completo de la interfaz del repositorio
    repo = {
      findById:   vi.fn(),
      findByName: vi.fn(),
      create:     vi.fn(),
      update:     vi.fn(),
      delete:     vi.fn(),
      getMetrics: vi.fn(),
    }
    service = new CampaignService(repo)
  })

  describe('create()', () => {
    it('debe crear campaña y persistir en repositorio', async () => {
      const input = { name: 'Black Friday 2025', budget: 5000 }
      const created = createCampaignFixture({ ...input, status: 'DRAFT' })

      vi.mocked(repo.findByName).mockResolvedValue(null)
      vi.mocked(repo.create).mockResolvedValue(created)

      const result = await service.create(input)

      expect(result).toEqual(created)
      expect(repo.create).toHaveBeenCalledWith(
        expect.objectContaining({ name: 'Black Friday 2025', status: 'DRAFT' })
      )
    })

    it('debe lanzar error si nombre duplicado', async () => {
      const existing = createCampaignFixture({ name: 'Black Friday 2025' })
      vi.mocked(repo.findByName).mockResolvedValue(existing)

      await expect(service.create({ name: 'Black Friday 2025', budget: 0 }))
        .rejects.toThrow('Campaign name already exists')
    })
  })

  describe('delete()', () => {
    it('debe lanzar CampaignActiveError si status es ACTIVE', async () => {
      const campaign = createCampaignFixture({ status: 'ACTIVE' })
      vi.mocked(repo.findById).mockResolvedValue(campaign)

      await expect(service.delete(campaign.id))
        .rejects.toThrow(CampaignActiveError)
    })
  })
})

📧 Patrón 3 — Mocking de Servicios Externos (SendGrid)

Mock completo del módulo @sendgrid/mail con Vitest para testear lógica de reintentos y limpieza de PII sin enviar emails reales.

email-notification.service.test.ts
// src/notifications/email-notification.service.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { EmailNotificationService, EmailDeliveryError } from './email-notification.service'
import { createLeadFixture } from '../tests/fixtures'

// Mock del módulo completo de SendGrid
vi.mock('@sendgrid/mail', () => ({
  default: {
    setApiKey: vi.fn(),
    send: vi.fn().mockResolvedValue([{ statusCode: 202 }]),
  },
}))

import sgMail from '@sendgrid/mail'

describe('EmailNotificationService', () => {
  let service: EmailNotificationService

  beforeEach(() => {
    service = new EmailNotificationService({ graceful: false })
    vi.clearAllMocks()
  })

  it('sendLeadAlert() debe incluir score y tier en el template', async () => {
    const lead = createLeadFixture({ email: 'pedro@empresa.com' })
    const scoreResult = { score: 88, tier: 'HOT' as const }

    await service.sendLeadAlert('comercial@leadflow.ai', lead, scoreResult)

    expect(sgMail.send).toHaveBeenCalledWith(
      expect.objectContaining({
        templateId: 'd-lead-alert-v2',
        dynamicTemplateData: expect.objectContaining({
          score: 88,
          tier: 'HOT',
          tierColor: '#ef4444',
        }),
      })
    )
  })

  it('debe reintentar 3 veces ante error 429 de SendGrid', async () => {
    const rateLimitError = { code: 429, message: 'Too Many Requests' }
    vi.mocked(sgMail.send)
      .mockRejectedValueOnce(rateLimitError)
      .mockRejectedValueOnce(rateLimitError)
      .mockResolvedValueOnce([{ statusCode: 202 }])

    const lead = createLeadFixture()
    await service.sendLeadAlert('dest@test.com', lead, { score: 60, tier: 'WARM' })

    expect(sgMail.send).toHaveBeenCalledTimes(3)
  })

  it('debe lanzar EmailDeliveryError tras 3 intentos fallidos', async () => {
    vi.mocked(sgMail.send).mockRejectedValue({ code: 500 })
    const lead = createLeadFixture()

    await expect(
      service.sendLeadAlert('dest@test.com', lead, { score: 50, tier: 'WARM' })
    ).rejects.toThrow(EmailDeliveryError)
  })
})

⚛️ Patrón 4 — Testing de React Hook

Hook useLeadScore testeado con renderHook + act de Testing Library. Incluye test de cancelación con AbortController y rollback optimista.

useLeadScore.test.tsx
// src/hooks/useLeadScore.test.tsx
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useLeadScore } from './useLeadScore'

const mockScore = { score: 78, tier: 'HOT' as const, factors: [] }

global.fetch = vi.fn()

describe('useLeadScore', () => {
  beforeEach(() => { vi.clearAllMocks() })

  it('debe retornar loading: true en estado inicial', () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: true, json: async () => mockScore,
    } as Response)

    const { result } = renderHook(() => useLeadScore('lead-abc-123'))
    expect(result.current.loading).toBe(true)
    expect(result.current.score).toBeNull()
  })

  it('debe retornar score tras fetch exitoso', async () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: true, json: async () => mockScore,
    } as Response)

    const { result } = renderHook(() => useLeadScore('lead-abc-123'))

    await waitFor(() => expect(result.current.loading).toBe(false))

    expect(result.current.score).toBe(78)
    expect(result.current.tier).toBe('HOT')
  })

  it('debe rollback si la API rechaza el score optimista', async () => {
    vi.mocked(fetch)
      .mockResolvedValueOnce({ ok: true, json: async () => mockScore } as Response)
      .mockResolvedValueOnce({ ok: false, status: 422 } as Response)

    const { result } = renderHook(() => useLeadScore('lead-abc-123'))
    await waitFor(() => expect(result.current.score).toBe(78))

    await act(async () => {
      result.current.updateOptimistic(95)
    })
    expect(result.current.score).toBe(95) // optimista

    await waitFor(() => expect(result.current.score).toBe(78)) // rollback
    expect(result.current.error).toBeDefined()
  })
})

🏭 Factories con @faker-js/faker

Fixtures tipados que generan datos realistas. Los overrides parciales permiten configurar solo los campos relevantes en cada test.

fixtures/lead.fixture.ts
import { faker } from '@faker-js/faker'
import type { Lead, Interaction } from '../types'

// Dominios corporativos para emails realistas
const CORP_DOMAINS = [
  'acme.com', 'techcorp.io', 'startup.es',
  'empresa.net', 'b2bsaas.com'
]

export function createLeadFixture(
  overrides?: Partial<Lead>
): Lead {
  const domain = faker.helpers.arrayElement(CORP_DOMAINS)
  return {
    id:        faker.string.uuid(),
    email:     `${faker.internet.username()}@${domain}`,
    firstName: faker.person.firstName(),
    lastName:  faker.person.lastName(),
    company: {
      name:     faker.company.name(),
      size:     faker.number.int({ min: 10, max: 500 }),
      industry: faker.helpers.arrayElement([
        'SaaS', 'Ecommerce', 'Fintech', 'Healthtech'
      ]),
      revenue:  faker.number.int({ min: 100_000, max: 10_000_000 }),
    },
    createdAt: faker.date.past(),
    ...overrides,
  }
}

export function createInteractionFixture(
  overrides?: Partial<Interaction>
): Interaction {
  return {
    id:        faker.string.uuid(),
    leadId:    faker.string.uuid(),
    type:      faker.helpers.arrayElement([
      'pageview', 'demo_requested',
      'whitepaper_download', 'pricing_visit'
    ]),
    timestamp: faker.date.recent({ days: 30 }),
    ...overrides,
  }
}
Datos generados — leadBatchFixture(5)faker seed: 42
Email Empresa Size Score
l.rodriguez@acme.com Martínez & Asociados SL 234 87 HOT
c.jimenez@techcorp.io Innovatek Solutions 45 61 WARM
admin@b2bsaas.com CloudPulse SaaS 512 93 HOT
m.garcia@startup.es Neolab Digital 8 28 COLD
ops@empresa.net Grupo Fernández Iberia 178 55 WARM

1 AAA

Arrange / Act / Assert — estructura clara en cada test, máx. 1 assert lógico.

2 DI

Inyección de dependencias — repositorios mockeados, sin BD real en tests unitarios.

3 Spy

vi.spyOn para observar llamadas sin reemplazar implementación completa.

4 Fixtures

Factories tipadas con faker — datos realistas, overrides parciales, no hardcoding.

⚙️ Configuración Vitest + GitHub Actions CI

Configuración lista para producción con umbral de cobertura 80% y pipeline CI que bloquea merges si la cobertura baja.

vitest.config.ts
// vitest.config.ts — LeadFlow AI
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/tests/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      exclude: [
        '**/*.d.ts', '**/*.config.*',
        '**/dist/**', 'src/tests/**',
      ],
      thresholds: {
        branches:   80,
        functions:  80,
        lines:      80,
        statements: 80,
      },
    },
    // Test paralelo por defecto — forks aislados
    pool: 'forks',
    poolOptions: {
      forks: { singleFork: false },
    },
  },
})
.github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install deps
        run: npm ci

      - name: Run tests + coverage
        run: npx vitest run --coverage
          # Falla el build si coverage < 80%

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
📦
npm ci
Install 247 packages from lockfile
✓ 8.2s
🔍
TypeScript Check
npx tsc --noEmit --strict
✓ 3.1s
⚡
Vitest — 47 tests
npx vitest run --coverage --reporter=verbose
✓ 1.24s
📊
Coverage thresholds
branches: 80% ✓ · functions: 91% ✓ · lines: 87% ✓
✓ passed
☁️
Upload coverage artifact
coverage/ → GitHub Actions artifacts
✓ done