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 keyEverything 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.urlTesting 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.urlTesting 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=shortStore 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.