Integrações de pagamento são a parte mais crítica de qualquer SaaS. Se o teste falha, você descobre em produção — e em produção significa dinheiro real, cliente cobrando errado e suporte lotado. Depois de integrar Stripe, Asaas e Mercado Pago em sistemas em produção, aprendi que testar pagamento não é como testar um CRUD. Exige uma estratégia específica.
Neste post, vou compartilhar a estratégia que uso para testar integrações de pagamento em Node.js — o que testar, o que mockar, quando usar sandbox e como garantir que webhooks, idempotência e fluxos assíncronos (PIX, boleto) funcionem de verdade.
O problema: pagamento não é CRUD
Um teste de CRUD verifica se dados entram e saem do banco corretamente. Pagamento envolve:
- Fluxos assíncronos — PIX e boleto não confirmam na hora. O status muda via webhook minutos ou horas depois
- Sistemas externos — o gateway pode ficar lento, retornar erro, ou mudar o formato da resposta sem aviso
- Idempotência — o mesmo webhook pode chegar 2, 3, 5 vezes. Seu sistema precisa processar apenas uma
- Dinheiro real — um bug cobra o cliente duas vezes ou não cobra nenhuma. Não tem 'rollback' fácil
- Múltiplos providers — cada gateway tem sua própria API, formato de webhook e comportamento de sandbox
A estratégia que funciona é testar em 4 camadas: unitário, contrato, integração com sandbox e resiliência.
Camada 1: testes unitários — lógica de negócio isolada
A primeira camada testa a lógica sem tocar em nenhum gateway. Aqui você testa: cálculo de valores, validação de status, regras de negócio (pode cancelar? pode reembolsar?), e a lógica de idempotência.
// Teste unitário — lógica de idempotência
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()
})
it('should reject invalid status transitions', async () => {
const processor = new PaymentEventProcessor(mockDb)
mockDb.order.findUnique.mockResolvedValue({ paymentStatus: 'REFUNDED' })
const event = createPaymentEvent({ type: 'payment.confirmed' })
await expect(processor.handle(event)).rejects.toThrow('Invalid transition: REFUNDED → CONFIRMED')
})
})O mock aqui é do banco de dados, não do gateway. A lógica de decidir se processa ou não, se a transição de status é válida, se o idempotency key já existe — tudo isso é código seu e precisa estar coberto.
Camada 2: testes de contrato — adapters de gateway
Se você usa o adapter pattern (e deveria, se integra com mais de um gateway), cada adapter precisa de um teste de contrato. O teste garante que o adapter transforma o payload do gateway no formato interno correto.
// Teste de contrato — cada adapter segue o mesmo formato de saída
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',
})
})
})
describe('MercadoPagoWebhookAdapter', () => {
it('should parse a PIX payment confirmation', () => {
const rawPayload = {
action: 'payment.updated',
data: { id: '12345' },
}
// Mercado Pago envia só o ID — precisa buscar os detalhes
const paymentDetails = {
id: 12345,
status: 'approved',
transaction_amount: 99.90,
payment_method_id: 'pix',
external_reference: 'order_456',
}
const adapter = new MercadoPagoWebhookAdapter()
const event = adapter.parse(rawPayload, paymentDetails)
expect(event).toEqual({
provider: 'mercadopago',
externalId: '12345',
type: 'payment.confirmed',
amount: 99.90,
method: 'PIX',
orderId: 'order_456',
})
})
})O ponto-chave: independente de qual gateway enviou, o output é sempre o mesmo formato. Se amanhã você adiciona um quarto gateway, o teste de contrato garante que o adapter produz o mesmo shape.
Camada 3: testes de integração com sandbox
Aqui é onde você bate na API real do gateway — mas no ambiente de sandbox. Cada provider tem o seu:
- Stripe: modo test com chaves
sk_test_*. Simula qualquer cenário com cartões especiais (4242...para sucesso,4000000000000002para recusa) - Asaas: ambiente sandbox em
sandbox.asaas.com. Simula PIX, boleto e cartão sem movimentar dinheiro - Mercado Pago: credenciais de teste com usuários de teste. Mais limitado — alguns fluxos de PIX não funcionam 100% no sandbox
O teste de integração com sandbox valida o fluxo completo: criar cobrança → receber webhook → processar pagamento → atualizar pedido.
// Teste de integração com sandbox — fluxo completo
describe('Payment Flow (Sandbox)', () => {
it('should create a PIX charge and process the webhook', async () => {
// 1. Cria a cobrança no 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 o webhook de confirmação (sandbox permite isso)
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 o pedido foi atualizado
const order = await db.order.findUnique({ where: { id: 'test_order_001' } })
expect(order.paymentStatus).toBe('CONFIRMED')
})
})Cuidado com testes de sandbox no CI. Eles dependem de rede e de um serviço externo. Se o sandbox do Asaas estiver fora, seu CI quebra. A solução é rodar esses testes em um job separado, com retry e timeout generoso, e não bloquear o merge por eles.
Camada 4: testes de resiliência — o que acontece quando dá errado
Essa é a camada que a maioria ignora. Não basta testar o caminho feliz — você precisa testar o que acontece quando:
- O gateway retorna timeout no meio de uma cobrança
- O webhook chega antes da resposta de criação da cobrança (race condition real)
- O webhook chega duplicado — 3 vezes em 2 segundos
- O webhook chega com assinatura inválida (tentativa de fraude)
- O gateway muda o formato do payload sem aviso (já aconteceu com Mercado Pago)
- O PIX expira e o cliente tenta pagar depois
// Testes de resiliência
describe('Payment Resilience', () => {
it('should handle duplicate webhooks gracefully', async () => {
const webhook = buildWebhook({ externalId: 'pay_dup', type: 'payment.confirmed' })
// Envia 3 vezes em sequência
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 retornam 200 (ack)
results.forEach(r => expect(r.status).toBe(200))
// Mas o pedido só foi atualizado UMA 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)
})
it('should handle expired PIX without crashing', async () => {
const webhook = buildWebhook({
externalId: 'pay_expired',
type: 'payment.expired',
})
const response = await request(app)
.post('/api/webhooks/asaas')
.send(webhook)
expect(response.status).toBe(200)
const order = await db.order.findUnique({ where: { externalPaymentId: 'pay_expired' } })
expect(order.paymentStatus).toBe('EXPIRED')
})
})Validação de assinatura: nunca pule
Cada gateway assina os webhooks de forma diferente. Se você não valida, qualquer pessoa que descubra sua URL de webhook pode simular pagamentos.
// Validação de assinatura por provider
function validateWebhookSignature(provider: string, req: Request): boolean {
switch (provider) {
case 'stripe': {
// Stripe usa HMAC-SHA256 no header 'stripe-signature'
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': {
// Asaas usa token fixo no header 'asaas-access-token'
return req.headers['asaas-access-token'] === ASAAS_WEBHOOK_TOKEN
}
case 'mercadopago': {
// Mercado Pago usa HMAC-SHA256 no header 'x-signature'
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
}
}Teste cada validação com assinatura correta E incorreta. É a primeira linha de defesa.
O que NÃO mockar
Regra prática que uso:
- Mocke o gateway nos testes unitários e de contrato — você está testando SUA lógica
- Não mocke o gateway nos testes de integração — use sandbox. Mock de gateway dá falsa sensação de segurança
- Nunca mocke o banco de dados nos testes de integração — use um banco real (PostgreSQL de teste). Mocks de banco escondem bugs de transação, constraint e race condition
- Mocke o tempo nos testes de expiração —
jest.useFakeTimers()para simular PIX expirando sem esperar 30 minutos
Estrutura de testes que uso em produção
tests/
├── unit/
│ ├── payment-event-processor.test.ts # Lógica de idempotência
│ ├── status-machine.test.ts # Transições de estado
│ └── charge-calculator.test.ts # Cálculo de valores
├── contract/
│ ├── asaas-adapter.test.ts # Formato do webhook Asaas
│ ├── mercadopago-adapter.test.ts # Formato do webhook MP
│ └── stripe-adapter.test.ts # Formato do webhook Stripe
├── integration/
│ ├── payment-flow.test.ts # Fluxo completo com sandbox
│ └── webhook-endpoint.test.ts # HTTP endpoint real
└── resilience/
├── duplicate-webhook.test.ts # Idempotência sob carga
├── invalid-signature.test.ts # Rejeição de fraude
└── expired-payment.test.ts # PIX/boleto expiradoLições de produção
- Sandbox não é produção. O Mercado Pago se comporta diferente no sandbox vs produção em cenários de PIX. Já tive teste passando no sandbox e falhando em produção porque o formato do webhook mudou. Solução: testes de contrato com snapshots do payload real
- Webhook duplicado é regra, não exceção. Stripe garante 'at least once delivery'. Asaas também. Se você não testa duplicação, vai cobrar o cliente duas vezes. Já vi acontecer
- Race condition no webhook. O webhook pode chegar ANTES da resposta de criação da cobrança voltar. Se seu código depende de um registro no banco que ainda não existe, vai dar erro. Solução: retry com backoff no processamento do webhook
- Log tudo. Quando um pagamento dá errado em produção, você precisa reconstruir a timeline. Logue: payload recebido, assinatura válida/inválida, idempotency key, status anterior, novo status, resultado da transação. Sem isso, debugar é impossível
- Monitore o tempo de resposta do webhook. Se seu endpoint leva mais de 5s pra responder, o gateway faz retry. Ack imediato + processamento em background é o padrão que funciona
Checklist antes de ir pra produção
- Validação de assinatura implementada para cada provider
- Idempotência testada com webhooks duplicados
- Transições de estado validadas (não aceitar 'confirmed' se já está 'refunded')
- Fluxo de PIX/boleto testado end-to-end no sandbox
- Expiração de pagamento tratada (PIX expira, boleto vence)
- Logs estruturados com contexto (orderId, provider, externalId)
- Endpoint de webhook responde em < 1s (ack + background processing)
- Reconciliação periódica implementada (conferir com o gateway se tudo bateu)
