Testing Stripe Payments: Test Cards, Webhooks, Stripe CLI, and Mocking

Testing Stripe Payments: Test Cards, Webhooks, Stripe CLI, and Mocking

Stripe provides a rich test environment with special test card numbers, a CLI for webhook simulation, and a test mode API. This guide covers unit testing with mocks, integration testing against the Stripe API, and webhook testing with the Stripe CLI.


Stripe Testing Overview

Stripe's testing infrastructure is among the best in the payments industry. You get:

  • Test API keys — separate from live keys, clearly labeled sk_test_...
  • Test card numbers — cards that simulate specific outcomes (success, decline, 3DS, insufficient funds)
  • Stripe CLI — forward webhooks to localhost and trigger events without real payments
  • Test clock — simulate time-based events like subscription renewals without waiting

The goal is to test every payment scenario your users might encounter without ever touching real money.


Setting Up Your Test Environment

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

<span class="hljs-comment"># Login to link CLI to your account
stripe login

<span class="hljs-comment"># Verify test mode is active
stripe config --list <span class="hljs-pipe">| grep test_mode

In your application:

// payments.js
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// In test env: STRIPE_SECRET_KEY=sk_test_...
// In production: STRIPE_SECRET_KEY=sk_live_...

Never mix live and test keys. Use environment variables and CI secrets to keep them separate.


Stripe Test Cards

Stripe provides test card numbers for different scenarios. These only work with test-mode API keys.

Common Test Cards

Card Number Result
4242 4242 4242 4242 Success
4000 0000 0000 0002 Card declined
4000 0000 0000 9995 Insufficient funds
4000 0000 0000 3220 3D Secure required
4000 0025 0000 3155 3D Secure 2 — authentication required
4000 0000 0000 0069 Expired card
4000 0000 0000 0127 Incorrect CVC
4000 0000 0000 0119 Processing error

Use any future expiry date and any 3-digit CVC with these card numbers.

International Cards

Visa (UK):    4000 0082 6000 0000
Visa (DE):    4000 0027 6000 0005
Visa (JP):    4000 0039 2000 0003
MC (US):      5555 5555 5555 4444
Amex:         3782 8224 6310 005

Unit Testing Payment Logic

For unit tests, you want to mock Stripe entirely — no network calls, instant feedback.

Mocking with Jest

// __mocks__/stripe.js
const stripeMock = {
  paymentIntents: {
    create: jest.fn(),
    retrieve: jest.fn(),
    confirm: jest.fn(),
    cancel: jest.fn(),
  },
  refunds: {
    create: jest.fn(),
  },
  webhooks: {
    constructEvent: jest.fn(),
  },
};

module.exports = jest.fn(() => stripeMock);
module.exports.stripeMock = stripeMock;
// payment-service.test.js
import { stripeMock } from '../__mocks__/stripe';
import { createOrder, refundOrder } from '../payment-service';

beforeEach(() => {
  jest.clearAllMocks();
});

describe('createOrder', () => {
  it('creates a payment intent and returns order ID', async () => {
    stripeMock.paymentIntents.create.mockResolvedValue({
      id: 'pi_test_123',
      status: 'requires_payment_method',
      client_secret: 'pi_test_123_secret_abc',
      amount: 4999,
    });

    const result = await createOrder({
      amount: 4999,
      currency: 'usd',
      userId: 'user-1',
    });

    expect(result.paymentIntentId).toBe('pi_test_123');
    expect(result.clientSecret).toBe('pi_test_123_secret_abc');
    expect(stripeMock.paymentIntents.create).toHaveBeenCalledWith(
      expect.objectContaining({
        amount: 4999,
        currency: 'usd',
      })
    );
  });

  it('throws when payment intent creation fails', async () => {
    stripeMock.paymentIntents.create.mockRejectedValue(
      new Error('Your card number is incorrect.')
    );

    await expect(createOrder({ amount: 100, currency: 'usd' })).rejects.toThrow(
      'Your card number is incorrect.'
    );
  });
});

describe('refundOrder', () => {
  it('creates a refund for a successful payment', async () => {
    stripeMock.refunds.create.mockResolvedValue({
      id: 're_test_456',
      status: 'succeeded',
      amount: 4999,
    });

    const result = await refundOrder('pi_test_123', 4999);

    expect(result.refundId).toBe('re_test_456');
    expect(result.status).toBe('succeeded');
  });

  it('creates a partial refund', async () => {
    stripeMock.refunds.create.mockResolvedValue({
      id: 're_test_789',
      status: 'succeeded',
      amount: 1000,
    });

    await refundOrder('pi_test_123', 1000);

    expect(stripeMock.refunds.create).toHaveBeenCalledWith({
      payment_intent: 'pi_test_123',
      amount: 1000,
    });
  });
});

Testing Webhook Signature Verification

// webhook-handler.test.js
import { stripeMock } from '../__mocks__/stripe';
import { handleStripeWebhook } from '../webhook-handler';

describe('handleStripeWebhook', () => {
  it('rejects webhooks with invalid signature', async () => {
    stripeMock.webhooks.constructEvent.mockImplementation(() => {
      throw new Error('No signatures found matching the expected signature for payload');
    });

    await expect(
      handleStripeWebhook('raw-payload', 'invalid-sig', 'whsec_test')
    ).rejects.toThrow('Invalid signature');
  });

  it('processes payment_intent.succeeded event', async () => {
    stripeMock.webhooks.constructEvent.mockReturnValue({
      type: 'payment_intent.succeeded',
      data: {
        object: {
          id: 'pi_test_123',
          metadata: { orderId: 'order-456' },
          amount: 9999,
        },
      },
    });

    const result = await handleStripeWebhook('payload', 'valid-sig', 'whsec_test');

    expect(result.orderId).toBe('order-456');
    expect(result.status).toBe('paid');
  });

  it('ignores unknown event types gracefully', async () => {
    stripeMock.webhooks.constructEvent.mockReturnValue({
      type: 'customer.created',
      data: { object: { id: 'cus_test' } },
    });

    const result = await handleStripeWebhook('payload', 'valid-sig', 'whsec_test');
    expect(result.ignored).toBe(true);
  });
});

Integration Testing Against the Stripe API

Integration tests use the real Stripe API with test keys. These are slower but catch issues your mocks can miss — API schema changes, quota limits, and real network behavior.

// stripe-integration.test.js
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY);

describe('Payment Intent lifecycle (integration)', () => {
  let paymentIntentId;

  it('creates a payment intent', async () => {
    const intent = await stripe.paymentIntents.create({
      amount: 2000,
      currency: 'usd',
      payment_method_types: ['card'],
      metadata: { test: 'true', orderId: 'int-test-order-1' },
    });

    paymentIntentId = intent.id;
    expect(intent.status).toBe('requires_payment_method');
    expect(intent.amount).toBe(2000);
    expect(intent.currency).toBe('usd');
  });

  it('confirms payment with test card', async () => {
    const intent = await stripe.paymentIntents.confirm(paymentIntentId, {
      payment_method: 'pm_card_visa',
      return_url: 'https://example.com',
    });

    expect(intent.status).toBe('succeeded');
  });

  it('creates a refund for the succeeded payment', async () => {
    const refund = await stripe.refunds.create({
      payment_intent: paymentIntentId,
    });

    expect(refund.status).toBe('succeeded');
    expect(refund.amount).toBe(2000);
  });
});

describe('Declined card scenarios', () => {
  it('fails with insufficient_funds error', async () => {
    const intent = await stripe.paymentIntents.create({
      amount: 2000,
      currency: 'usd',
      payment_method: 'pm_card_visa_chargeDeclinedInsufficientFunds',
      confirm: true,
      return_url: 'https://example.com',
    });

    // Status should reflect decline, not throw
    expect(['requires_payment_method', 'canceled']).toContain(intent.status);

    // Get the charge to inspect the failure
    if (intent.latest_charge) {
      const charge = await stripe.charges.retrieve(intent.latest_charge);
      expect(charge.failure_code).toBe('insufficient_funds');
    }
  });
});

Webhook Testing with Stripe CLI

The Stripe CLI lets you test webhooks against your local development server.

Forward Webhooks to Localhost

# Forward all Stripe events to your local webhook endpoint
stripe listen --forward-to localhost:3000/webhooks/stripe

<span class="hljs-comment"># Output includes your webhook signing secret:
<span class="hljs-comment"># > Ready! Your webhook signing secret is whsec_test_abc123...

Set that secret in your .env:

STRIPE_WEBHOOK_SECRET=whsec_test_abc123...

Trigger Specific Events

# Trigger a payment success event
stripe trigger payment_intent.succeeded

<span class="hljs-comment"># Trigger payment failure
stripe trigger payment_intent.payment_failed

<span class="hljs-comment"># Trigger subscription events
stripe trigger customer.subscription.created
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed

<span class="hljs-comment"># Trigger with specific data
stripe trigger payment_intent.succeeded \
  --add payment_intent:metadata.orderId=order-123

Automated Webhook Testing Script

#!/bin/bash
<span class="hljs-comment"># test-webhooks.sh

SERVER_URL=<span class="hljs-string">"http://localhost:3000"
STRIPE_CLI=<span class="hljs-string">"stripe"

<span class="hljs-built_in">echo <span class="hljs-string">"Starting webhook listener..."
<span class="hljs-variable">$STRIPE_CLI listen --forward-to <span class="hljs-variable">$SERVER_URL/webhooks/stripe &
LISTENER_PID=$!

<span class="hljs-built_in">sleep 2  <span class="hljs-comment"># Wait for listener to start

<span class="hljs-built_in">echo <span class="hljs-string">"Testing payment_intent.succeeded..."
<span class="hljs-variable">$STRIPE_CLI trigger payment_intent.succeeded
<span class="hljs-built_in">sleep 1

<span class="hljs-built_in">echo <span class="hljs-string">"Testing payment_intent.payment_failed..."
<span class="hljs-variable">$STRIPE_CLI trigger payment_intent.payment_failed
<span class="hljs-built_in">sleep 1

<span class="hljs-built_in">echo <span class="hljs-string">"Testing refund.created..."
<span class="hljs-variable">$STRIPE_CLI trigger charge.refunded
<span class="hljs-built_in">sleep 1

<span class="hljs-comment"># Check your server logs for handler output
<span class="hljs-built_in">echo <span class="hljs-string">"Webhook test complete. Check server logs."

<span class="hljs-built_in">kill <span class="hljs-variable">$LISTENER_PID

Testing 3D Secure Flows

3D Secure (3DS) adds an authentication step that requires special handling in tests.

// 3ds-flow.test.js (Playwright)
test('completes 3D Secure authentication', async ({ page }) => {
  await page.goto('/checkout');

  // Fill form with 3DS-required test card
  const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]');
  await stripeFrame.locator('[name="cardnumber"]').fill('4000 0025 0000 3155');
  await stripeFrame.locator('[name="exp-date"]').fill('12/28');
  await stripeFrame.locator('[name="cvc"]').fill('123');

  await page.click('[data-testid="pay-button"]');

  // Stripe opens 3DS iframe
  const threeDSFrame = page.frameLocator('[title="3D Secure authentication"]');
  await threeDSFrame.locator('#test-source-authorize-3ds').click();

  // Verify redirect after successful 3DS
  await expect(page).toHaveURL(/order-confirmation/, { timeout: 10000 });
});

test('handles 3D Secure authentication failure', async ({ page }) => {
  await page.goto('/checkout');

  const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]');
  await stripeFrame.locator('[name="cardnumber"]').fill('4000 0025 0000 3155');
  await stripeFrame.locator('[name="exp-date"]').fill('12/28');
  await stripeFrame.locator('[name="cvc"]').fill('123');

  await page.click('[data-testid="pay-button"]');

  const threeDSFrame = page.frameLocator('[title="3D Secure authentication"]');
  await threeDSFrame.locator('#test-source-fail-3ds').click();

  await expect(page.locator('[data-testid="payment-error"]')).toContainText(
    'authentication'
  );
});

Testing Idempotency

Stripe supports idempotency keys to prevent duplicate charges on network retries. Test that your code uses them correctly:

// idempotency.test.js
import { createPaymentIntent } from '../payments';

test('uses idempotency key to prevent duplicate charges', async () => {
  const orderId = 'order-idempotency-test';

  // Simulate calling twice (e.g., user double-submits, or network retry)
  const intent1 = await createPaymentIntent({ amount: 1000, orderId });
  const intent2 = await createPaymentIntent({ amount: 1000, orderId });

  // Both calls should return the same payment intent
  expect(intent1.id).toBe(intent2.id);
});

Your implementation should include the order ID in the idempotency key:

// payments.js
export async function createPaymentIntent({ amount, currency = 'usd', orderId }) {
  return stripe.paymentIntents.create(
    { amount, currency, metadata: { orderId } },
    { idempotencyKey: `order-${orderId}` }
  );
}

Environment Setup Checklist

Before running Stripe tests in CI:

# GitHub Actions
env:
  STRIPE_TEST_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}
  STRIPE_TEST_PUBLISHABLE_KEY: ${{ secrets.STRIPE_TEST_PUBLISHABLE_KEY }}
  STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}

Local .env.test:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...  # from `stripe listen` output

Never commit real Stripe keys. Use dotenv-safe or similar to enforce required variables are set before tests run.


Summary

Testing Stripe payments has three layers:

  1. Unit tests — mock the Stripe SDK entirely, test your business logic in isolation
  2. Integration tests — call the real Stripe test API with test cards to verify end-to-end flows
  3. Webhook tests — use stripe listen + stripe trigger locally, or real webhook events in staging

Cover the happy path (successful payment), the most common failure modes (decline, insufficient funds, 3DS required), and the retry/idempotency path. That covers 95% of what users will encounter in production.

Read more