Vinicius Aguiar
Engenharia

Estratégia de testes para integrações de pagamento em Node.js

17 de abril de 2026 · 11 min de leitura

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, 4000000000000002 para 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 expirado

Lições de produção

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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)