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_modeIn 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 005Unit 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-123Automated 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_PIDTesting 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` outputNever 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:
- Unit tests — mock the Stripe SDK entirely, test your business logic in isolation
- Integration tests — call the real Stripe test API with test cards to verify end-to-end flows
- Webhook tests — use
stripe listen+stripe triggerlocally, 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.