Testing Stripe Checkout: Test Cards, Edge Cases, and End-to-End Flows

Testing Stripe Checkout: Test Cards, Edge Cases, and End-to-End Flows

Testing Stripe checkout requires covering more than the happy path. 3D Secure authentication, declined cards, insufficient funds, SCA compliance, and idempotency all need explicit test coverage. This guide covers Stripe's test card catalog, Checkout Session API testing, and full E2E automation with Playwright.


Why Checkout Testing Is Harder Than It Looks

A payment form that "works" for you in development can fail for:

  • Cards requiring 3D Secure authentication (common in Europe, UK)
  • Cards that expire during the payment flow
  • Network timeouts between your server and Stripe
  • Idempotency key collisions on retry
  • Webhook delivery failures after successful payment

If you haven't tested these scenarios, they will fail in production.


Stripe's Test Mode

All testing happens in test mode — a separate environment where no real money moves:

  • Use test API keys (sk_test_..., pk_test_...)
  • All test card numbers work only in test mode
  • Webhooks can be tested locally with stripe listen
  • Stripe's dashboard shows test transactions separately
# Switch environments in code
stripe = Stripe::Stripe::new(sk_test_key)  <span class="hljs-comment"># Ruby
stripe = Stripe(api_key=sk_test_key)       <span class="hljs-comment"># Python
const stripe = require(<span class="hljs-string">'stripe')(process.env.STRIPE_TEST_SECRET_KEY);  <span class="hljs-comment"># Node

The Test Card Catalog

Core Test Cards

Scenario Card Number Exp CVC
Success (Visa) 4242 4242 4242 4242 Any future Any
Success (Mastercard) 5555 5555 5555 4444 Any future Any
Success (Amex) 3782 822463 10005 Any future Any 4-digit
Success (Debit) 4000 0566 5566 5556 Any future Any
Declined — generic 4000 0000 0000 0002 Any future Any
Declined — insufficient funds 4000 0000 0000 9995 Any future Any
Declined — lost card 4000 0000 0000 9987 Any future Any
Declined — stolen card 4000 0000 0000 9979 Any future Any
Expired card 4000 0000 0000 0069 Any past Any
Incorrect CVC 4000 0000 0000 0101 Any future Any
Processing error 4000 0000 0000 0119 Any future Any

3D Secure Cards

Scenario Card Number
3DS required 4000 0027 6000 3184
3DS optional (supported) 4000 0025 0000 3155
3DS required — fails 4000 0084 0000 1629
3DS required — auth fails 4000 0000 0000 3220

International and SCA Cards

Scenario Card Number
SCA authenticated (UK) 4000 0082 6000 0000
Indian cards (requires auth) 4000 0035 6000 0008
Brazilian cards 4000 0007 6000 0002

Testing Payment Intents via API

Test payment scenarios programmatically:

import stripe
import pytest

stripe.api_key = "sk_test_your_key_here"

def create_test_payment_intent(amount: int, currency: str = "usd") -> stripe.PaymentIntent:
    return stripe.PaymentIntent.create(
        amount=amount,
        currency=currency,
        payment_method_types=["card"],
        metadata={"test": "true"}
    )

def test_payment_intent_created_with_correct_amount():
    pi = create_test_payment_intent(2999)  # $29.99
    
    assert pi.status == "requires_payment_method"
    assert pi.amount == 2999
    assert pi.currency == "usd"
    assert pi.id.startswith("pi_")

def test_confirm_with_success_card():
    pi = create_test_payment_intent(5000)
    
    # Attach a test payment method
    pm = stripe.PaymentMethod.create(
        type="card",
        card={
            "number": "4242424242424242",
            "exp_month": 12,
            "exp_year": 2028,
            "cvc": "123"
        }
    )
    
    # Confirm the payment intent
    confirmed = stripe.PaymentIntent.confirm(
        pi.id,
        payment_method=pm.id
    )
    
    assert confirmed.status == "succeeded"

def test_declined_card_returns_card_error():
    pi = create_test_payment_intent(5000)
    
    pm = stripe.PaymentMethod.create(
        type="card",
        card={
            "number": "4000000000000002",  # decline card
            "exp_month": 12,
            "exp_year": 2028,
            "cvc": "123"
        }
    )
    
    with pytest.raises(stripe.error.CardError) as exc_info:
        stripe.PaymentIntent.confirm(pi.id, payment_method=pm.id)
    
    error = exc_info.value
    assert error.code == "card_declined"
    assert error.decline_code == "generic_decline"

def test_insufficient_funds_returns_correct_decline_code():
    pi = create_test_payment_intent(5000)
    
    pm = stripe.PaymentMethod.create(
        type="card",
        card={
            "number": "4000000000009995",  # insufficient funds
            "exp_month": 12,
            "exp_year": 2028,
            "cvc": "123"
        }
    )
    
    with pytest.raises(stripe.error.CardError) as exc_info:
        stripe.PaymentIntent.confirm(pi.id, payment_method=pm.id)
    
    assert exc_info.value.decline_code == "insufficient_funds"

def test_idempotency_prevents_duplicate_charges():
    idempotency_key = "test-order-abc123-charge-v1"
    
    pi1 = stripe.PaymentIntent.create(
        amount=5000,
        currency="usd",
        payment_method_types=["card"],
        idempotency_key=idempotency_key  # same key = same result
    )
    
    pi2 = stripe.PaymentIntent.create(
        amount=9999,  # different amount — but same key
        currency="usd",
        payment_method_types=["card"],
        idempotency_key=idempotency_key  # returns first result, ignores this amount
    )
    
    # Same payment intent returned
    assert pi1.id == pi2.id
    assert pi1.amount == pi2.amount == 5000  # not 9999

Testing Stripe Checkout Sessions

Stripe Checkout is the hosted payment page. Test that your session creation is correct:

def test_creates_checkout_session():
    session = stripe.checkout.Session.create(
        payment_method_types=["card"],
        line_items=[{
            "price_data": {
                "currency": "usd",
                "product_data": {"name": "Widget Pro"},
                "unit_amount": 2999,
            },
            "quantity": 1,
        }],
        mode="payment",
        success_url="https://example.com/success?session_id={CHECKOUT_SESSION_ID}",
        cancel_url="https://example.com/cancel",
    )
    
    assert session.status == "open"
    assert session.payment_status == "unpaid"
    assert session.url.startswith("https://checkout.stripe.com")
    assert session.amount_total == 2999

def test_checkout_session_with_customer_email():
    session = stripe.checkout.Session.create(
        payment_method_types=["card"],
        customer_email="alice@test.com",
        line_items=[{
            "price_data": {
                "currency": "usd",
                "product_data": {"name": "Widget"},
                "unit_amount": 1000
            },
            "quantity": 1
        }],
        mode="payment",
        success_url="https://example.com/success",
        cancel_url="https://example.com/cancel"
    )
    
    assert session.customer_email == "alice@test.com"

def test_checkout_session_with_coupon():
    # Create a test coupon
    coupon = stripe.Coupon.create(
        percent_off=20,
        duration="once",
        id="TEST20"
    )
    
    session = stripe.checkout.Session.create(
        payment_method_types=["card"],
        line_items=[{
            "price_data": {
                "currency": "usd",
                "product_data": {"name": "Widget"},
                "unit_amount": 5000
            },
            "quantity": 1
        }],
        discounts=[{"coupon": "TEST20"}],
        mode="payment",
        success_url="https://example.com/success",
        cancel_url="https://example.com/cancel"
    )
    
    # 20% off $50 = $40
    assert session.amount_total == 4000
    
    # Cleanup
    stripe.Coupon.delete("TEST20")

E2E Checkout Flow with Playwright

Test the full checkout experience in a real browser:

# tests/e2e/test_stripe_checkout.py
import re
import pytest
from playwright.sync_api import Page, expect

STORE_URL = "https://your-store.test"

def fill_stripe_card(page: Page, card_number: str = "4242424242424242"):
    """Fill Stripe's card element (handles iframe)."""
    # Stripe card element is in an iframe
    card_frame = page.frame_locator('iframe[name*="__privateStripeFrame"]').first
    card_frame.locator('[placeholder="Card number"]').fill(card_number)
    card_frame.locator('[placeholder="MM / YY"]').fill("12 / 28")
    card_frame.locator('[placeholder="CVC"]').fill("123")
    card_frame.locator('[placeholder="ZIP"]').fill("97201")

def test_successful_checkout(page: Page):
    page.goto(f"{STORE_URL}/products/widget-pro")
    page.click("button:has-text('Buy Now')")
    
    # Redirected to Stripe Checkout
    expect(page).to_have_url(re.compile("checkout.stripe.com"))
    
    # Fill email
    page.fill('[placeholder="Email"]', "alice@test.com")
    
    # Fill card details
    fill_stripe_card(page, "4242424242424242")
    
    # Submit
    page.click('button:has-text("Pay")')
    
    # Redirected back to success page
    expect(page).to_have_url(re.compile("your-store.test/success"))
    expect(page.locator("text=Thank you")).to_be_visible()

def test_declined_card_shows_error(page: Page):
    page.goto(f"{STORE_URL}/products/widget-pro")
    page.click("button:has-text('Buy Now')")
    
    expect(page).to_have_url(re.compile("checkout.stripe.com"))
    page.fill('[placeholder="Email"]', "alice@test.com")
    
    # Declined card
    fill_stripe_card(page, "4000000000000002")
    page.click('button:has-text("Pay")')
    
    # Error message should appear — NOT redirected
    expect(page.locator("text=Your card has been declined")).to_be_visible()
    expect(page).to_have_url(re.compile("checkout.stripe.com"))  # stayed on checkout

def test_3ds_authentication_flow(page: Page):
    page.goto(f"{STORE_URL}/products/widget-pro")
    page.click("button:has-text('Buy Now')")
    
    page.fill('[placeholder="Email"]', "alice@test.com")
    fill_stripe_card(page, "4000002760003184")  # 3DS required card
    page.click('button:has-text("Pay")')
    
    # 3DS modal appears
    stripe_3ds_frame = page.frame_locator('[name="stripe-challenge-frame"]')
    expect(stripe_3ds_frame.locator("text=Authenticate")).to_be_visible(timeout=10000)
    
    # Complete 3DS (test mode has a button)
    stripe_3ds_frame.locator("text=Complete authentication").click()
    
    # Redirected to success
    expect(page).to_have_url(re.compile("your-store.test/success"), timeout=15000)

Testing Refunds

def test_full_refund():
    # Create and confirm a payment
    pm = stripe.PaymentMethod.create(
        type="card",
        card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2028, "cvc": "123"}
    )
    pi = stripe.PaymentIntent.create(amount=5000, currency="usd", payment_method_types=["card"])
    stripe.PaymentIntent.confirm(pi.id, payment_method=pm.id)
    
    # Issue full refund
    refund = stripe.Refund.create(payment_intent=pi.id)
    
    assert refund.status == "succeeded"
    assert refund.amount == 5000

def test_partial_refund():
    pm = stripe.PaymentMethod.create(
        type="card",
        card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2028, "cvc": "123"}
    )
    pi = stripe.PaymentIntent.create(amount=10000, currency="usd", payment_method_types=["card"])
    stripe.PaymentIntent.confirm(pi.id, payment_method=pm.id)
    
    # Partial refund ($30 of $100)
    refund = stripe.Refund.create(payment_intent=pi.id, amount=3000)
    
    assert refund.amount == 3000
    
    # Verify PI still shows the remaining amount
    pi_updated = stripe.PaymentIntent.retrieve(pi.id)
    assert pi_updated.amount_received == 10000  # full amount received

Stripe Checkout Test Checklist

Before each release:

  • Successful payment with Visa test card
  • Successful payment with Mastercard test card
  • Card declined — generic decline
  • Card declined — insufficient funds
  • Card declined — expired card
  • 3D Secure flow — authenticate and complete
  • 3D Secure flow — cancel authentication
  • Coupon/discount applied correctly
  • Refund — full refund
  • Refund — partial refund
  • Retry behavior on network error
  • Webhook received after successful payment
  • Order status updated after webhook

Next Steps

Read more