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