Testing Payment Flows End-to-End with Stripe Test Mode

Testing Payment Flows End-to-End with Stripe Test Mode

Stripe's test mode lets you run complete payment flows without real money. Test card numbers like 4242 4242 4242 4242 simulate successful payments, while other numbers simulate declines, insufficient funds, and 3D Secure authentication. This guide covers how to test the full checkout flow using Playwright, handle authentication scenarios, and structure payment tests in CI.

Key Takeaways

Test mode is a complete payment sandbox. Stripe test mode uses the same API, the same webhook infrastructure, and the same dashboard as production — just with fake money. Your test code is nearly identical to production code.

Use Stripe's test card catalog systematically. There are over 20 test card numbers for different scenarios. Don't just test with 4242 — test 4000 0000 0000 9995 (insufficient funds), 4000 0027 6000 3184 (3D Secure required), and country-specific cards.

3D Secure tests require browser automation. SCA/3D Secure authentication shows a browser iframe. You can't test this with a pure HTTP client — you need Playwright or Cypress to interact with the Stripe authentication modal.

Test the decline recovery flow, not just the happy path. A user with a declined card should be able to update their payment method and retry. This multi-step flow is where bugs live.

Never use real card numbers in tests. Use Stripe test card numbers exclusively. Real cards in test mode will fail, but the habit of avoiding them prevents accidents when switching environments.

Stripe Test Mode Basics

Stripe's test mode is a complete replica of the production environment. You get separate API keys (prefixed with sk_test_ and pk_test_), test data that doesn't affect production, and a dashboard showing all test transactions.

Enable test mode by using your test API keys:

import stripe
stripe.api_key = "sk_test_your_test_key"  # Not the live key

Everything works the same: PaymentIntents, Customers, Subscriptions, Invoices — all are created in the test environment and visible in the test dashboard at dashboard.stripe.com when the "Test mode" toggle is on.

Test Card Numbers

Stripe provides a catalog of test cards. Each number simulates a specific outcome:

Card number Scenario
4242 4242 4242 4242 Payment succeeds
4000 0000 0000 9995 Insufficient funds
4000 0000 0000 0002 Card declined (generic)
4000 0000 0000 9987 Decline with lost_card code
4000 0027 6000 3184 3D Secure required, then succeeds
4000 0082 6000 3178 3D Secure required, then fails
4000 0000 0000 3220 3DS 2 — authentication required
4100 0000 0000 0019 Card is blocked (fraudulent)

Use any future expiry date (e.g. 12/34) and any 3-digit CVV (e.g. 123). The card number determines the outcome — expiry and CVV are not validated in test mode.

Testing Checkout with Playwright

For Stripe Checkout (Stripe-hosted payment page), you interact with Stripe's UI. Playwright handles this straightforwardly:

# test_checkout.py
import pytest
from playwright.sync_api import Page

def test_successful_checkout(page: Page):
    # Navigate to your checkout
    page.goto("https://your-app.com/checkout?product=basic-plan")
    
    # Click pay button (opens Stripe Checkout or Payment Element)
    page.click("[data-testid='pay-button']")
    
    # Wait for Stripe's payment form
    page.wait_for_selector("#card-number")
    
    # Fill in test card details
    page.fill("#card-number", "4242 4242 4242 4242")
    page.fill("#card-expiry", "12 / 34")
    page.fill("#card-cvc", "123")
    page.fill("#billing-name", "Test User")
    
    # Submit payment
    page.click("[data-testid='submit-payment']")
    
    # Assert success state
    page.wait_for_url("**/success**")
    assert page.locator("[data-testid='success-message']").is_visible()


def test_declined_card_shows_error(page: Page):
    page.goto("https://your-app.com/checkout?product=basic-plan")
    page.click("[data-testid='pay-button']")
    page.wait_for_selector("#card-number")
    
    page.fill("#card-number", "4000 0000 0000 0002")  # Generic decline
    page.fill("#card-expiry", "12 / 34")
    page.fill("#card-cvc", "123")
    
    page.click("[data-testid='submit-payment']")
    
    # Error message should appear
    error = page.locator("[data-testid='card-error']")
    assert error.is_visible()
    assert "declined" in error.text_content().lower()
    
    # User should still be on checkout page, able to retry
    assert "/checkout" in page.url

Testing with Stripe Elements (iframes)

If you're using Stripe Elements (embedded payment form), card inputs are inside iframes. Playwright handles iframes with frame_locator:

def test_stripe_elements_checkout(page: Page):
    page.goto("https://your-app.com/checkout")
    
    # Stripe Elements renders inside iframes
    card_frame = page.frame_locator('iframe[title="Secure card number input frame"]')
    card_frame.locator('[placeholder="Card number"]').fill("4242 4242 4242 4242")
    
    expiry_frame = page.frame_locator('iframe[title="Secure expiration date input frame"]')
    expiry_frame.locator('[placeholder="MM / YY"]').fill("12 / 34")
    
    cvc_frame = page.frame_locator('iframe[title="Secure CVC input frame"]')
    cvc_frame.locator('[placeholder="CVC"]').fill("123")
    
    page.click("#submit-button")
    page.wait_for_url("**/confirmation**")
    assert page.locator(".confirmation-number").is_visible()

Testing 3D Secure Authentication

3DS authentication shows an additional modal for bank verification. Stripe's test mode provides special cards and a simulated authentication flow:

def test_3ds_authentication_success(page: Page):
    page.goto("https://your-app.com/checkout")
    
    # Use 3DS test card
    card_frame = page.frame_locator('iframe[title="Secure card number input frame"]')
    card_frame.locator('[placeholder="Card number"]').fill("4000 0027 6000 3184")
    
    expiry_frame = page.frame_locator('iframe[title="Secure expiration date input frame"]')
    expiry_frame.locator('[placeholder="MM / YY"]').fill("12 / 34")
    
    cvc_frame = page.frame_locator('iframe[title="Secure CVC input frame"]')
    cvc_frame.locator('[placeholder="CVC"]').fill("123")
    
    page.click("#submit-button")
    
    # 3DS modal appears — Stripe's test modal has a "Complete" button
    # Wait for the Stripe 3DS iframe to appear
    page.wait_for_selector('iframe[name="__privateStripeFrame"]', timeout=10000)
    
    three_ds_frame = page.frame_locator('iframe[name="__privateStripeFrame"]')
    # In test mode, Stripe shows "Authorize Test Payment" button
    three_ds_frame.locator("text=Authorize Test Payment").click()
    
    # Payment completes after 3DS
    page.wait_for_url("**/confirmation**")
    assert page.locator(".confirmation-number").is_visible()


def test_3ds_authentication_failure(page: Page):
    page.goto("https://your-app.com/checkout")
    
    card_frame = page.frame_locator('iframe[title="Secure card number input frame"]')
    card_frame.locator('[placeholder="Card number"]').fill("4000 0082 6000 3178")
    
    expiry_frame = page.frame_locator('iframe[title="Secure expiration date input frame"]')
    expiry_frame.locator('[placeholder="MM / YY"]').fill("12 / 34")
    
    cvc_frame = page.frame_locator('iframe[title="Secure CVC input frame"]')
    cvc_frame.locator('[placeholder="CVC"]').fill("123")
    
    page.click("#submit-button")
    
    # 3DS modal appears
    page.wait_for_selector('iframe[name="__privateStripeFrame"]', timeout=10000)
    three_ds_frame = page.frame_locator('iframe[name="__privateStripeFrame"]')
    
    # User fails 3DS authentication
    three_ds_frame.locator("text=Fail Test Payment").click()
    
    # Should show error, stay on checkout
    error = page.locator("[data-testid='payment-error']")
    assert error.is_visible()
    assert "/checkout" in page.url

Testing the Backend (API-Level)

For backend tests that don't need a browser, use the Stripe Python SDK directly with test credentials:

# test_payment_backend.py
import stripe
import pytest

stripe.api_key = "sk_test_your_test_key"


def test_create_payment_intent():
    intent = stripe.PaymentIntent.create(
        amount=5000,  # $50.00
        currency="usd",
        payment_method_types=["card"],
    )
    assert intent.status == "requires_payment_method"
    assert intent.amount == 5000
    assert intent.currency == "usd"


def test_confirm_payment_intent_with_test_card():
    intent = stripe.PaymentIntent.create(
        amount=5000,
        currency="usd",
        payment_method_types=["card"],
        confirm=True,
        payment_method="pm_card_visa",  # Stripe test payment method token
    )
    assert intent.status == "succeeded"


def test_confirm_payment_intent_declined():
    with pytest.raises(stripe.error.CardError) as exc_info:
        stripe.PaymentIntent.create(
            amount=5000,
            currency="usd",
            payment_method_types=["card"],
            confirm=True,
            payment_method="pm_card_chargeDeclined",
        )
    assert exc_info.value.code == "card_declined"

Stripe provides predefined test payment method tokens like pm_card_visa, pm_card_chargeDeclined, pm_card_chargeDeclinedInsufficientFunds that map to the same scenarios as the test card numbers but for API-level tests.

CI Configuration

# .github/workflows/payment-tests.yml
name: Payment Integration Tests

on: [push, pull_request]

env:
  STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}
  STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_TEST_PUBLISHABLE_KEY }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: playwright install chromium
      - run: pytest tests/payments/ -v --tb=short

Store STRIPE_TEST_SECRET_KEY in your CI secrets. Never use live keys in CI. Stripe test keys start with sk_test_ — if you ever see sk_live_ in a CI environment variable, stop immediately.

What to Test

Cover these scenarios at minimum:

Happy path: Successful payment → confirmation page → webhook received → order created

Decline scenarios:

  • Generic decline
  • Insufficient funds
  • Lost/stolen card
  • Expired card (use a past expiry date)

Authentication scenarios:

  • 3DS authentication required → user completes → payment succeeds
  • 3DS authentication required → user fails → payment fails

Recovery flow:

  • Payment declined → user updates card → retries → succeeds
  • Network error during payment → user retries → succeeds

Edge cases:

  • Double-click submit (duplicate charge prevention)
  • User navigates away during payment
  • Webhook received before redirect (race condition in order fulfillment)

Summary

Stripe's test mode gives you a complete payment sandbox with no real money at risk. The test card catalog covers every scenario from simple declines to 3DS authentication. For frontend tests, Playwright handles Stripe's iframes. For backend tests, use predefined payment method tokens. The critical thing is testing beyond the happy path — declined cards and 3DS failures are where real user experience problems hide.

Read more