Vinicius Aguiar
Ingénierie

Stratégie de tests pour les intégrations de paiement en Node.js

17 avril 2026 · 11 min de lecture

Les intégrations de paiement sont la partie la plus critique de n'importe quel SaaS. Si le test échoue, vous le découvrez en production — et en production, cela veut dire de l'argent réel, des clients facturés à tort et un support saturé. Après avoir intégré Stripe, Asaas et Mercado Pago dans des systèmes en production, j'ai appris que tester un paiement n'a rien à voir avec tester un CRUD. Cela exige une stratégie spécifique.

Dans ce post, je vais partager la stratégie que j'utilise pour tester les intégrations de paiement en Node.js — quoi tester, quoi mocker, quand utiliser le sandbox et comment garantir que les webhooks, l'idempotence et les flux asynchrones (PIX, boleto) fonctionnent vraiment.

Le problème : un paiement, ce n'est pas du CRUD

Un test de CRUD vérifie que les données entrent et sortent correctement de la base. Un paiement implique :

  • Des flux asynchrones — le PIX et le boleto ne confirment pas sur le moment. Le statut change via webhook, des minutes ou des heures plus tard
  • Des systèmes externes — le gateway peut devenir lent, renvoyer une erreur, ou changer le format de la réponse sans prévenir
  • L'idempotence — le même webhook peut arriver 2, 3, 5 fois. Votre système ne doit en traiter qu'un seul
  • De l'argent réel — un bug facture le client deux fois ou ne le facture pas du tout. Il n'y a pas de « rollback » facile
  • Plusieurs providers — chaque gateway a sa propre API, son propre format de webhook et son propre comportement de sandbox

La stratégie qui fonctionne consiste à tester en 4 couches : unitaire, contrat, intégration avec sandbox et résilience.

Couche 1 : tests unitaires — logique métier isolée

La première couche teste la logique sans toucher à aucun gateway. Ici, vous testez : le calcul des montants, la validation des statuts, les règles métier (peut-on annuler ? peut-on rembourser ?), et la logique d'idempotence.

// Test unitaire — logique d'idempotence
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')
  })
})

Le mock ici porte sur la base de données, pas sur le gateway. La logique qui décide de traiter ou non, si la transition de statut est valide, si l'idempotency key existe déjà — tout cela, c'est votre code et il doit être couvert.

Couche 2 : tests de contrat — adapters de gateway

Si vous utilisez l'adapter pattern (et vous devriez, si vous intégrez plus d'un gateway), chaque adapter a besoin d'un test de contrat. Le test garantit que l'adapter transforme le payload du gateway dans le bon format interne.

// Test de contrat — chaque adapter suit le même format de sortie
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 n'envoie que l'ID — il faut aller chercher les détails
    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',
    })
  })
})

Le point clé : quel que soit le gateway qui l'a envoyé, l'output est toujours au même format. Si demain vous ajoutez un quatrième gateway, le test de contrat garantit que l'adapter produit le même shape.

Couche 3 : tests d'intégration avec sandbox

C'est ici que vous tapez sur l'API réelle du gateway — mais dans l'environnement de sandbox. Chaque provider a le sien :

  • Stripe : mode test avec des clés sk_test_*. Simule n'importe quel scénario avec des cartes spéciales (4242... pour un succès, 4000000000000002 pour un refus)
  • Asaas : environnement sandbox sur sandbox.asaas.com. Simule le PIX, le boleto et la carte sans faire bouger d'argent
  • Mercado Pago : identifiants de test avec des utilisateurs de test. Plus limité — certains flux PIX ne fonctionnent pas à 100 % dans le sandbox

Le test d'intégration avec sandbox valide le flux complet : créer la demande de paiement → recevoir le webhook → traiter le paiement → mettre à jour la commande.

// Test d'intégration avec sandbox — flux complet
describe('Payment Flow (Sandbox)', () => {
  it('should create a PIX charge and process the webhook', async () => {
    // 1. Crée la demande de paiement dans le 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. Simule le webhook de confirmation (le sandbox le permet)
    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. Vérifie que la commande a été mise à jour
    const order = await db.order.findUnique({ where: { id: 'test_order_001' } })
    expect(order.paymentStatus).toBe('CONFIRMED')
  })
})

Attention aux tests de sandbox dans le CI. Ils dépendent du réseau et d'un service externe. Si le sandbox d'Asaas est hors service, votre CI casse. La solution est de faire tourner ces tests dans un job séparé, avec retry et un timeout généreux, et de ne pas bloquer le merge à cause d'eux.

Couche 4 : tests de résilience — ce qui se passe quand ça tourne mal

C'est la couche que la plupart des gens ignorent. Il ne suffit pas de tester le chemin heureux — vous devez tester ce qui se passe quand :

  • Le gateway renvoie un timeout au milieu d'une demande de paiement
  • Le webhook arrive avant la réponse de création de la demande de paiement (une vraie race condition)
  • Le webhook arrive en double — 3 fois en 2 secondes
  • Le webhook arrive avec une signature invalide (tentative de fraude)
  • Le gateway change le format du payload sans prévenir (c'est déjà arrivé avec Mercado Pago)
  • Le PIX expire et le client essaie de payer après
// Tests de résilience
describe('Payment Resilience', () => {
  it('should handle duplicate webhooks gracefully', async () => {
    const webhook = buildWebhook({ externalId: 'pay_dup', type: 'payment.confirmed' })

    // Envoie 3 fois de suite
    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),
    ])

    // Tous renvoient 200 (ack)
    results.forEach(r => expect(r.status).toBe(200))

    // Mais la commande n'a été mise à jour QU'UNE seule fois
    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')
  })
})

Validation de signature : ne la sautez jamais

Chaque gateway signe les webhooks différemment. Si vous ne validez pas, n'importe qui découvrant votre URL de webhook peut simuler des paiements.

// Validation de signature par provider
function validateWebhookSignature(provider: string, req: Request): boolean {
  switch (provider) {
    case 'stripe': {
      // Stripe utilise HMAC-SHA256 dans le 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 utilise un token fixe dans le header 'asaas-access-token'
      return req.headers['asaas-access-token'] === ASAAS_WEBHOOK_TOKEN
    }
    case 'mercadopago': {
      // Mercado Pago utilise HMAC-SHA256 dans le 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
  }
}

Testez chaque validation avec une signature correcte ET incorrecte. C'est la première ligne de défense.

Ce qu'il ne faut PAS mocker

La règle pratique que j'utilise :

  • Mockez le gateway dans les tests unitaires et de contrat — vous testez VOTRE logique
  • Ne mockez pas le gateway dans les tests d'intégration — utilisez le sandbox. Un mock de gateway donne un faux sentiment de sécurité
  • Ne mockez jamais la base de données dans les tests d'intégration — utilisez une vraie base (PostgreSQL de test). Les mocks de base cachent des bugs de transaction, de constraint et de race condition
  • Mockez le temps dans les tests d'expiration — jest.useFakeTimers() pour simuler un PIX qui expire sans attendre 30 minutes

La structure de tests que j'utilise en production

tests/
├── unit/
│   ├── payment-event-processor.test.ts    # Logique d'idempotence
│   ├── status-machine.test.ts             # Transitions d'état
│   └── charge-calculator.test.ts          # Calcul des montants
├── contract/
│   ├── asaas-adapter.test.ts              # Format du webhook Asaas
│   ├── mercadopago-adapter.test.ts        # Format du webhook MP
│   └── stripe-adapter.test.ts             # Format du webhook Stripe
├── integration/
│   ├── payment-flow.test.ts               # Flux complet avec sandbox
│   └── webhook-endpoint.test.ts           # Endpoint HTTP réel
└── resilience/
    ├── duplicate-webhook.test.ts           # Idempotence sous charge
    ├── invalid-signature.test.ts           # Rejet de fraude
    └── expired-payment.test.ts            # PIX/boleto expiré

Leçons de production

  1. Le sandbox n'est pas la production. Mercado Pago se comporte différemment en sandbox vs en production dans les scénarios PIX. J'ai déjà eu un test qui passait en sandbox et qui échouait en production parce que le format du webhook avait changé. Solution : des tests de contrat avec des snapshots du payload réel
  2. Le webhook dupliqué est la règle, pas l'exception. Stripe garantit « at least once delivery ». Asaas aussi. Si vous ne testez pas la duplication, vous allez facturer le client deux fois. Je l'ai déjà vu arriver
  3. Race condition sur le webhook. Le webhook peut arriver AVANT que la réponse de création de la demande de paiement ne revienne. Si votre code dépend d'un enregistrement en base qui n'existe pas encore, cela va provoquer une erreur. Solution : retry avec backoff dans le traitement du webhook
  4. Loguez tout. Quand un paiement tourne mal en production, vous devez reconstruire la timeline. Loguez : le payload reçu, la signature valide/invalide, l'idempotency key, le statut précédent, le nouveau statut, le résultat de la transaction. Sans cela, débugger est impossible
  5. Surveillez le temps de réponse du webhook. Si votre endpoint met plus de 5s à répondre, le gateway fait un retry. Ack immédiat + traitement en background est le pattern qui fonctionne

Checklist avant de passer en production

  • Validation de signature implémentée pour chaque provider
  • Idempotence testée avec des webhooks dupliqués
  • Transitions d'état validées (ne pas accepter « confirmed » si c'est déjà « refunded »)
  • Flux PIX/boleto testé end-to-end dans le sandbox
  • Expiration de paiement gérée (le PIX expire, le boleto arrive à échéance)
  • Logs structurés avec contexte (orderId, provider, externalId)
  • Endpoint de webhook qui répond en < 1s (ack + background processing)
  • Réconciliation périodique implémentée (vérifier avec le gateway que tout correspond)