Vinicius Aguiar
Engineering

Testing strategy for payment integrations in Node.js

Apr 17, 2026 · 11 min read

Payment integrations are the most critical part of any SaaS. If testing fails, you find out in production — and in production that means real money, customers charged incorrectly and support overloaded. After integrating Stripe, Asaas and Mercado Pago in production systems, I learned that testing payments is nothing like testing a CRUD. It requires a specific strategy.

In this post, I'll share the strategy I use for testing payment integrations in Node.js — what to test, what to mock, when to use sandbox and how to ensure webhooks, idempotency and async flows (PIX, bank slip) actually work.

The problem: payments aren't CRUD

A CRUD test verifies that data goes in and out of the database correctly. Payments involve:

  • Async flows — PIX and bank slips don't confirm instantly. Status changes via webhook minutes or hours later
  • External systems — the gateway can be slow, return errors, or change response format without notice
  • Idempotency — the same webhook can arrive 2, 3, 5 times. Your system must process only one
  • Real money — a bug charges the customer twice or not at all. There's no easy 'rollback'
  • Multiple providers — each gateway has its own API, webhook format and sandbox behavior

The strategy that works is testing in 4 layers: unit, contract, sandbox integration and resilience.

Layer 1: unit tests — isolated business logic

The first layer tests logic without touching any gateway. Here you test: value calculations, status validation, business rules (can cancel? can refund?), and idempotency logic.

// Unit test — idempotency logic
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')
  })
})

The mock here is for the database, not the gateway. The logic of deciding whether to process or not, whether the status transition is valid, whether the idempotency key already exists — all of that is your code and needs to be covered.

Layer 2: contract tests — gateway adapters

If you use the adapter pattern (and you should, when integrating with more than one gateway), each adapter needs a contract test. The test ensures the adapter transforms the gateway payload into the correct internal format.

// Contract test — each adapter follows the same output format
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',
    })
  })
})

The key point: regardless of which gateway sent it, the output is always the same format. If tomorrow you add a fourth gateway, the contract test ensures the adapter produces the same shape.

Layer 3: integration tests with sandbox

This is where you hit the real gateway API — but in the sandbox environment. Each provider has its own:

  • Stripe: test mode with sk_test_* keys. Simulates any scenario with special cards (4242... for success, 4000000000000002 for decline)
  • Asaas: sandbox environment at sandbox.asaas.com. Simulates PIX, bank slip and card without moving money
  • Mercado Pago: test credentials with test users. More limited — some PIX flows don't work 100% in sandbox

The sandbox integration test validates the complete flow: create charge → receive webhook → process payment → update order.

// Integration test with sandbox — complete flow
describe('Payment Flow (Sandbox)', () => {
  it('should create a PIX charge and process the webhook', async () => {
    // 1. Create charge in 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. Simulate confirmation webhook (sandbox allows this)
    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. Verify order was updated
    const order = await db.order.findUnique({ where: { id: 'test_order_001' } })
    expect(order.paymentStatus).toBe('CONFIRMED')
  })
})

Be careful with sandbox tests in CI. They depend on network and an external service. If Asaas sandbox is down, your CI breaks. The solution is to run these tests in a separate job, with retry and generous timeout, and not block merges on them.

Layer 4: resilience tests — what happens when things go wrong

This is the layer most people skip. It's not enough to test the happy path — you need to test what happens when:

  • The gateway returns a timeout mid-charge
  • The webhook arrives before the charge creation response (real race condition)
  • The webhook arrives duplicated — 3 times in 2 seconds
  • The webhook arrives with an invalid signature (fraud attempt)
  • The gateway changes the payload format without notice (happened with Mercado Pago)
  • PIX expires and the customer tries to pay after
// Resilience tests
describe('Payment Resilience', () => {
  it('should handle duplicate webhooks gracefully', async () => {
    const webhook = buildWebhook({ externalId: 'pay_dup', type: 'payment.confirmed' })

    // Send 3 times in sequence
    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),
    ])

    // All return 200 (ack)
    results.forEach(r => expect(r.status).toBe(200))

    // But the order was only updated ONCE
    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')
  })
})

Webhook signature validation: never skip it

Each gateway signs webhooks differently. If you don't validate, anyone who discovers your webhook URL can simulate payments.

// Signature validation per provider
function validateWebhookSignature(provider: string, req: Request): boolean {
  switch (provider) {
    case 'stripe': {
      // Stripe uses HMAC-SHA256 in 'stripe-signature' header
      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 uses fixed token in 'asaas-access-token' header
      return req.headers['asaas-access-token'] === ASAAS_WEBHOOK_TOKEN
    }
    case 'mercadopago': {
      // Mercado Pago uses HMAC-SHA256 in 'x-signature' header
      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
  }
}

Test each validation with both correct AND incorrect signatures. It's the first line of defense.

What NOT to mock

Practical rule I follow:

  • Mock the gateway in unit and contract tests — you're testing YOUR logic
  • Don't mock the gateway in integration tests — use sandbox. Gateway mocks give false confidence
  • Never mock the database in integration tests — use a real database (test PostgreSQL). Database mocks hide transaction, constraint and race condition bugs
  • Mock time in expiration tests — jest.useFakeTimers() to simulate PIX expiring without waiting 30 minutes

Test structure I use in production

tests/
├── unit/
│   ├── payment-event-processor.test.ts    # Idempotency logic
│   ├── status-machine.test.ts             # State transitions
│   └── charge-calculator.test.ts          # Value calculations
├── contract/
│   ├── asaas-adapter.test.ts              # Asaas webhook format
│   ├── mercadopago-adapter.test.ts        # MP webhook format
│   └── stripe-adapter.test.ts             # Stripe webhook format
├── integration/
│   ├── payment-flow.test.ts               # Complete flow with sandbox
│   └── webhook-endpoint.test.ts           # Real HTTP endpoint
└── resilience/
    ├── duplicate-webhook.test.ts           # Idempotency under load
    ├── invalid-signature.test.ts           # Fraud rejection
    └── expired-payment.test.ts            # Expired PIX/bank slip

Production lessons

  1. Sandbox is not production. Mercado Pago behaves differently in sandbox vs production for PIX scenarios. I've had tests passing in sandbox and failing in production because the webhook format changed. Solution: contract tests with snapshots of real payloads
  2. Duplicate webhooks are the rule, not the exception. Stripe guarantees 'at least once delivery'. Asaas too. If you don't test duplication, you'll charge the customer twice. I've seen it happen
  3. Webhook race condition. The webhook can arrive BEFORE the charge creation response comes back. If your code depends on a database record that doesn't exist yet, it'll error. Solution: retry with backoff in webhook processing
  4. Log everything. When a payment goes wrong in production, you need to reconstruct the timeline. Log: received payload, valid/invalid signature, idempotency key, previous status, new status, transaction result. Without this, debugging is impossible
  5. Monitor webhook response time. If your endpoint takes more than 5s to respond, the gateway retries. Immediate ack + background processing is the pattern that works

Checklist before going to production

  • Signature validation implemented for each provider
  • Idempotency tested with duplicate webhooks
  • State transitions validated (don't accept 'confirmed' if already 'refunded')
  • PIX/bank slip flow tested end-to-end in sandbox
  • Payment expiration handled (PIX expires, bank slip due date passes)
  • Structured logs with context (orderId, provider, externalId)
  • Webhook endpoint responds in < 1s (ack + background processing)
  • Periodic reconciliation implemented (check with gateway if everything matches)