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,4000000000000002para 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 expiradoLecciones de producción
- 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
- 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
- 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
- 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
- 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)
