Vinicius Aguiar
Ingeniería

Estrategia de testing para integraciones de pago en Node.js

17 de abr. de 2026 · 11 min de lectura

Las integraciones de pago son la parte más crítica de cualquier SaaS. Si el testing falla, te enterás en producción — y en producción eso significa dinero real, clientes cobrados incorrectamente y soporte saturado. Después de integrar Stripe, Asaas y Mercado Pago en sistemas en producción, aprendí que testear pagos no es como testear un CRUD. Requiere una estrategia específica.

En este post, voy a compartir la estrategia que uso para testear integraciones de pago en Node.js — qué testear, qué mockear, cuándo usar sandbox y cómo garantizar que webhooks, idempotencia y flujos asíncronos (PIX, boleto) funcionen de verdad.

El problema: pagos no es CRUD

Un test de CRUD verifica que los datos entran y salen de la base de datos correctamente. Pagos involucra:

  • Flujos asíncronos — PIX y boletos no confirman al instante. El estado cambia vía webhook minutos u horas después
  • Sistemas externos — el gateway puede estar lento, retornar errores, o cambiar el formato de respuesta sin aviso
  • Idempotencia — el mismo webhook puede llegar 2, 3, 5 veces. Tu sistema debe procesar solo uno
  • Dinero real — un bug cobra al cliente dos veces o no cobra ninguna. No hay 'rollback' fácil
  • Múltiples providers — cada gateway tiene su propia API, formato de webhook y comportamiento de sandbox

La estrategia que funciona es testear en 4 capas: unitario, contrato, integración con sandbox y resiliencia.

Capa 1: tests unitarios — lógica de negocio aislada

La primera capa testea la lógica sin tocar ningún gateway. Aquí testeas: cálculo de valores, validación de estados, reglas de negocio (¿puede cancelar? ¿puede reembolsar?), y la lógica de idempotencia.

// Test unitario — lógica de idempotencia
describe('PaymentEventProcessor', () => {
  it('should process a new event', async () => {
    const processor = new PaymentEventProcessor(mockDb)
    const event = createPaymentEvent({ externalId: 'evt_123', type: 'payment.confirmed' })

    const result = await processor.handle(event)

    expect(result.status).toBe('processed')
    expect(mockDb.paymentEvent.create).toHaveBeenCalledWith(
      expect.objectContaining({ idempotencyKey: 'evt_123:payment.confirmed' })
    )
  })

  it('should skip duplicate events', async () => {
    const processor = new PaymentEventProcessor(mockDb)
    mockDb.paymentEvent.findUnique.mockResolvedValue({ id: 'existing' })

    const event = createPaymentEvent({ externalId: 'evt_123', type: 'payment.confirmed' })
    const result = await processor.handle(event)

    expect(result.status).toBe('already_processed')
    expect(mockDb.order.update).not.toHaveBeenCalled()
  })
})

El mock aquí es de la base de datos, no del gateway. La lógica de decidir si procesa o no, si la transición de estado es válida, si el idempotency key ya existe — todo eso es código tuyo y necesita estar cubierto.

Capa 2: tests de contrato — adapters de gateway

Si usas el adapter pattern (y deberías, si integras con más de un gateway), cada adapter necesita un test de contrato. El test garantiza que el adapter transforma el payload del gateway al formato interno correcto.

// Test de contrato — cada adapter sigue el mismo formato de salida
describe('AsaasWebhookAdapter', () => {
  it('should parse a PIX payment confirmation', () => {
    const rawPayload = {
      event: 'PAYMENT_CONFIRMED',
      payment: {
        id: 'pay_abc123',
        value: 99.90,
        billingType: 'PIX',
        status: 'CONFIRMED',
        externalReference: 'order_456',
      },
    }

    const adapter = new AsaasWebhookAdapter()
    const event = adapter.parse(rawPayload)

    expect(event).toEqual({
      provider: 'asaas',
      externalId: 'pay_abc123',
      type: 'payment.confirmed',
      amount: 99.90,
      method: 'PIX',
      orderId: 'order_456',
    })
  })
})

El punto clave: independiente de cuál gateway envió, la salida es siempre el mismo formato. Si mañana agregas un cuarto gateway, el test de contrato garantiza que el adapter produce el mismo shape.

Capa 3: tests de integración con sandbox

Aquí es donde le pegas a la API real del gateway — pero en el ambiente de sandbox. Cada provider tiene el suyo:

  • Stripe: modo test con claves sk_test_*. Simula cualquier escenario con tarjetas especiales (4242... para éxito, 4000000000000002 para rechazo)
  • Asaas: ambiente sandbox en sandbox.asaas.com. Simula PIX, boleto y tarjeta sin mover dinero
  • Mercado Pago: credenciales de test con usuarios de test. Más limitado — algunos flujos de PIX no funcionan 100% en sandbox

El test de integración con sandbox valida el flujo completo: crear cobro → recibir webhook → procesar pago → actualizar pedido.

// Test de integración con sandbox — flujo completo
describe('Payment Flow (Sandbox)', () => {
  it('should create a PIX charge and process the webhook', async () => {
    // 1. Crea el cobro en sandbox
    const charge = await paymentService.createCharge({
      provider: 'asaas',
      amount: 49.90,
      method: 'PIX',
      orderId: 'test_order_001',
    })

    expect(charge.externalId).toBeDefined()
    expect(charge.pixQrCode).toBeDefined()
    expect(charge.status).toBe('PENDING')

    // 2. Simula el webhook de confirmación
    const webhookPayload = buildSandboxWebhook('asaas', {
      paymentId: charge.externalId,
      event: 'PAYMENT_CONFIRMED',
    })

    const response = await request(app)
      .post('/api/webhooks/asaas')
      .set('asaas-access-token', SANDBOX_WEBHOOK_TOKEN)
      .send(webhookPayload)

    expect(response.status).toBe(200)

    // 3. Verifica que el pedido fue actualizado
    const order = await db.order.findUnique({ where: { id: 'test_order_001' } })
    expect(order.paymentStatus).toBe('CONFIRMED')
  })
})

Cuidado con tests de sandbox en CI. Dependen de red y de un servicio externo. Si el sandbox de Asaas está caído, tu CI se rompe. La solución es correr estos tests en un job separado, con retry y timeout generoso, y no bloquear el merge por ellos.

Capa 4: tests de resiliencia — qué pasa cuando sale mal

Esta es la capa que la mayoría ignora. No basta testear el camino feliz — necesitas testear qué pasa cuando:

  • El gateway retorna timeout en medio de un cobro
  • El webhook llega antes de la respuesta de creación del cobro (race condition real)
  • El webhook llega duplicado — 3 veces en 2 segundos
  • El webhook llega con firma inválida (intento de fraude)
  • El gateway cambia el formato del payload sin aviso (ya pasó con Mercado Pago)
  • El PIX expira y el cliente intenta pagar después
// Tests de resiliencia
describe('Payment Resilience', () => {
  it('should handle duplicate webhooks gracefully', async () => {
    const webhook = buildWebhook({ externalId: 'pay_dup', type: 'payment.confirmed' })

    // Envía 3 veces en secuencia
    const results = await Promise.all([
      request(app).post('/api/webhooks/asaas').send(webhook),
      request(app).post('/api/webhooks/asaas').send(webhook),
      request(app).post('/api/webhooks/asaas').send(webhook),
    ])

    // Todos retornan 200 (ack)
    results.forEach(r => expect(r.status).toBe(200))

    // Pero el pedido solo fue actualizado UNA vez
    const events = await db.paymentEvent.findMany({
      where: { externalId: 'pay_dup' },
    })
    expect(events).toHaveLength(1)
  })

  it('should reject webhooks with invalid signature', async () => {
    const webhook = buildWebhook({ externalId: 'pay_fake' })

    const response = await request(app)
      .post('/api/webhooks/asaas')
      .set('asaas-access-token', 'invalid_token')
      .send(webhook)

    expect(response.status).toBe(401)
  })
})

Validación de firma: nunca la saltes

Cada gateway firma los webhooks de forma diferente. Si no validas, cualquier persona que descubra tu URL de webhook puede simular pagos.

// Validación de firma por provider
function validateWebhookSignature(provider: string, req: Request): boolean {
  switch (provider) {
    case 'stripe': {
      const sig = req.headers['stripe-signature'] as string
      try {
        stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET)
        return true
      } catch {
        return false
      }
    }
    case 'asaas': {
      return req.headers['asaas-access-token'] === ASAAS_WEBHOOK_TOKEN
    }
    case 'mercadopago': {
      const xSignature = req.headers['x-signature'] as string
      const xRequestId = req.headers['x-request-id'] as string
      const dataId = req.query['data.id'] as string
      const computed = crypto
        .createHmac('sha256', MP_WEBHOOK_SECRET)
        .update(`id:${dataId};request-id:${xRequestId};ts:${extractTs(xSignature)};`)
        .digest('hex')
      return extractHash(xSignature) === computed
    }
    default:
      return false
  }
}

Testea cada validación con firma correcta E incorrecta. Es la primera línea de defensa.

Qué NO mockear

Regla práctica que sigo:

  • Mockea el gateway en tests unitarios y de contrato — estás testeando TU lógica
  • No mockees el gateway en tests de integración — usa sandbox. Mocks de gateway dan falsa sensación de seguridad
  • Nunca mockees la base de datos en tests de integración — usa una base real (PostgreSQL de test). Mocks de base esconden bugs de transacción, constraint y race condition
  • Mockea el tiempo en tests de expiración — jest.useFakeTimers() para simular PIX expirando sin esperar 30 minutos

Estructura de tests que uso en producción

tests/
├── unit/
│   ├── payment-event-processor.test.ts    # Lógica de idempotencia
│   ├── status-machine.test.ts             # Transiciones de estado
│   └── charge-calculator.test.ts          # Cálculo de valores
├── contract/
│   ├── asaas-adapter.test.ts              # Formato del webhook Asaas
│   ├── mercadopago-adapter.test.ts        # Formato del webhook MP
│   └── stripe-adapter.test.ts             # Formato del webhook Stripe
├── integration/
│   ├── payment-flow.test.ts               # Flujo completo con sandbox
│   └── webhook-endpoint.test.ts           # Endpoint HTTP real
└── resilience/
    ├── duplicate-webhook.test.ts           # Idempotencia bajo carga
    ├── invalid-signature.test.ts           # Rechazo de fraude
    └── expired-payment.test.ts            # PIX/boleto expirado

Lecciones de producción

  1. Sandbox no es producción. Mercado Pago se comporta diferente en sandbox vs producción en escenarios de PIX. Ya tuve tests pasando en sandbox y fallando en producción porque el formato del webhook cambió. Solución: tests de contrato con snapshots del payload real
  2. Webhook duplicado es regla, no excepción. Stripe garantiza 'at least once delivery'. Asaas también. Si no testeas duplicación, vas a cobrar al cliente dos veces. Ya lo vi pasar
  3. Race condition en webhook. El webhook puede llegar ANTES de que la respuesta de creación del cobro vuelva. Si tu código depende de un registro en la base que todavía no existe, va a dar error. Solución: retry con backoff en el procesamiento del webhook
  4. Logea todo. Cuando un pago sale mal en producción, necesitas reconstruir la timeline. Logea: payload recibido, firma válida/inválida, idempotency key, estado anterior, nuevo estado, resultado de la transacción. Sin esto, debuggear es imposible
  5. Monitorea el tiempo de respuesta del webhook. Si tu endpoint tarda más de 5s en responder, el gateway hace retry. Ack inmediato + procesamiento en background es el patrón que funciona

Checklist antes de ir a producción

  • Validación de firma implementada para cada provider
  • Idempotencia testeada con webhooks duplicados
  • Transiciones de estado validadas (no aceptar 'confirmed' si ya está 'refunded')
  • Flujo de PIX/boleto testeado end-to-end en sandbox
  • Expiración de pago tratada (PIX expira, boleto vence)
  • Logs estructurados con contexto (orderId, provider, externalId)
  • Endpoint de webhook responde en < 1s (ack + background processing)
  • Reconciliación periódica implementada (verificar con el gateway si todo coincide)