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.
Ejecución completa de la suite en 1.24 s. Todos los módulos del motor de scoring superan el umbral de 80%.
Tests unitarios de LeadScorer: clase que calcula puntuación 0–100 con factores firmográficos, interacciones y penalizaciones. Patrón AAA (Arrange / Act / Assert).
// 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) }) }) })
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.
// 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) }) }) })
Mock completo del módulo @sendgrid/mail con Vitest para testear lógica de reintentos y limpieza de PII sin enviar emails reales.
// 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) }) })
Hook useLeadScore testeado con renderHook + act de Testing Library. Incluye test de cancelación con AbortController y rollback optimista.
// 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() }) })
Fixtures tipados que generan datos realistas. Los overrides parciales permiten configurar solo los campos relevantes en cada test.
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, } }
| 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 |
Arrange / Act / Assert — estructura clara en cada test, máx. 1 assert lógico.
Inyección de dependencias — repositorios mockeados, sin BD real en tests unitarios.
vi.spyOn para observar llamadas sin reemplazar implementación completa.
Factories tipadas con faker — datos realistas, overrides parciales, no hardcoding.
Configuración lista para producción con umbral de cobertura 80% y pipeline CI que bloquea merges si la cobertura baja.
// 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 }, }, }, })
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/