Payment testing is one of the most critical yet most overlooked areas in e-commerce and SaaS platforms. Getting it wrong means either blocking legitimate customers or, worse, shipping a bug that silently fails for real transactions. We'll walk through testing Stripe Checkout: real-world lessons and examples that our teams have learned the hard way—so you don't have to.
Why Payment Testing Matters
Unlike most features, payment flows touch your business directly: revenue, compliance, and customer trust. A single untested edge case can cascade into chargebacks, failed subscriptions, or angry support tickets at 2 AM. Stripe's hosted checkout page abstracts complexity away from your frontend, which is good for security but makes testing feel opaque. The truth is, it's not opaque at all—you just need the right approach.
Use Test Mode Keys
Your first rule: never, ever test with live keys. Stripe provides separate test and live API keys specifically for this reason. Test mode is completely isolated; no real charges occur, and you get deterministic behavior that makes your tests repeatable.
Every test environment in your pipeline—local, staging, CI/CD—should use test keys. If you're working in a codebase where someone has hardcoded a live key anywhere except production secrets, fix it immediately. Set up environment-based configuration early:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// In .env.test:
// STRIPE_SECRET_KEY=sk_test_...
// In .env.production:
// STRIPE_SECRET_KEY=sk_live_...
Test mode keys behave identically to live keys but operate in a sandbox where all test charges are discarded at the end of each day. You can inspect test charges in your Stripe Dashboard under the Test Data toggle. This gives you visibility into exactly what your code is attempting—no surprises on Friday afternoon before a release.
A common pattern in larger teams: create a separate Stripe account for each environment (dev, staging, production). This prevents any accidental cross-environment pollution and makes audit trails crystal clear. Your CI/CD pipeline should inject test keys at build time; never commit them.
Test Cards for Success and Decline
Stripe publishes an official set of test card numbers that trigger specific outcomes. These aren't random; they're designed to simulate real-world scenarios without touching the payment network:
| Card Number | Scenario | CVC | Expiry |
|---|---|---|---|
| 4242 4242 4242 4242 | Successful charge | Any 3 digits | Any future date |
| 4000 0000 0000 0002 | Card declined | Any 3 digits | Any future date |
| 4000 0025 0000 3155 | Requires 3D Secure auth | Any 3 digits | Any future date |
| 5555 5555 5555 4444 | Visa success (Mastercard format) | Any 3 digits | Any future date |
In your Playwright tests, you'll interact with Stripe's hosted checkout page. Here's how to test a successful payment flow:
import { test, expect } from '@playwright/test';
test('should complete payment with valid card', async ({ page }) => {
// Navigate to your checkout page
await page.goto('http://localhost:3000/checkout');
// Your app renders the Stripe Checkout button
await page.click('[data-test-id="checkout-button"]');
// Wait for the Stripe hosted page to load
await page.waitForURL('**/pay.stripe.com/**');
// Fill in test card
const cardFrame = page.frameLocator('iframe[name="stripe"]');
await cardFrame.locator('input[name="cardnumber"]').fill('4242424242424242');
await cardFrame.locator('input[name="exp-date"]').fill('1230');
await cardFrame.locator('input[name="cvc"]').fill('123');
// Submit the form
await page.click('button:has-text("Pay")');
// Verify redirect to success page
await page.waitForURL('**/success**');
expect(page.url()).toContain('/success');
});
For decline scenarios, test that your error handling works:
test('should handle declined card gracefully', async ({ page }) => {
await page.goto('http://localhost:3000/checkout');
await page.click('[data-test-id="checkout-button"]');
await page.waitForURL('**/pay.stripe.com/**');
const cardFrame = page.frameLocator('iframe[name="stripe"]');
await cardFrame.locator('input[name="cardnumber"]').fill('4000000000000002');
await cardFrame.locator('input[name="exp-date"]').fill('1230');
await cardFrame.locator('input[name="cvc"]').fill('123');
await page.click('button:has-text("Pay")');
// Should see error message, not redirect
await expect(page.locator('text=Your card was declined')).toBeVisible();
});
The key insight: test both the happy path and the failure path. A payment form that silently fails or redirects to the wrong page on decline will confuse customers and lose you revenue. Every code path must be exercised.
Handle the Hosted Checkout Page
Stripe's hosted checkout page is an iframe that sits on their domain. This creates a cross-origin boundary that limits your JavaScript access, which is intentional for security. You can't inspect the DOM or directly read field values—you're filling forms blind from your perspective.
The practical approach: use frame locators and explicit waits. Playwright's frameLocator API lets you pierce into Stripe's iframe and interact with elements inside it:
const stripeFrame = page.frameLocator('iframe[src*="stripe.com"]');
await stripeFrame.locator('input[placeholder="Card number"]').fill('4242424242424242');
But here's where many teams go wrong: they assume the iframe is immediately ready. In reality, Stripe's checkout page loads asynchronously. You need explicit waits:
test('checkout flow with proper frame wait', async ({ page }) => {
await page.goto('http://localhost:3000/checkout?session_id=test_123');
// Wait for the Stripe session to be mounted
const stripeFrame = page.frameLocator('iframe[src*="stripe.com"]');
await page.waitForFunction(() => {
return document.querySelector('iframe[src*="stripe.com"]') !== null;
});
// Now it's safe to interact
await stripeFrame.locator('[aria-label="Email"]').fill('test@example.com');
await stripeFrame.locator('[aria-label="Card number"]').fill('4242424242424242');
await stripeFrame.locator('[aria-label="Expiration date"]').fill('1230');
await stripeFrame.locator('[aria-label="CVC"]').fill('123');
// Submit
await stripeFrame.locator('button[type="submit"]').click();
// Verify success redirect
await page.waitForURL('**/return?session_id=**');
});
One more critical detail: if you're using Stripe's client-side library to create the session, ensure your test waits for the session ID to be set before proceeding. Some teams hardcode test session IDs in their tests, which works but isn't representative of real flow timing.
Verify Webhooks via API After Payment
Here's where many payment tests fall short: they verify the redirect happened, then assume the payment was successful. In reality, Stripe's async webhooks might still be processing. Your application's state isn't truly updated until the checkout.session.completed webhook is handled.
The correct pattern is to verify the webhook indirectly through your application's API:
test('should process webhook and update order status', async ({ page, request }) => {
// 1. Complete the checkout flow
await page.goto('http://localhost:3000/checkout');
await page.click('[data-test-id="checkout-button"]');
// ... (fill card, submit payment)
// Extract the session ID from the redirect URL
const url = page.url();
const sessionId = new URL(url).searchParams.get('session_id');
// 2. Poll the API to verify the order was created
let orderStatus = null;
for (let i = 0; i < 10; i++) {
const response = await request.get(`/api/orders?session_id=${sessionId}`);
const data = await response.json();
if (data.status === 'paid') {
orderStatus = data.status;
break;
}
// Wait 500ms before retry
await page.waitForTimeout(500);
}
expect(orderStatus).toBe('paid');
});
This pattern is more resilient than trying to directly mock or spy on webhooks. In a real integration test, you're verifying that the end-to-end flow works: payment completes, webhook is received by your server, your database updates, and your API reflects the change.
For faster tests in CI, some teams use Stripe's test webhooks API to manually trigger webhook events:
// In your test setup or teardown
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
test.beforeEach(async () => {
// Create a test session
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_test_123', quantity: 1 }],
mode: 'payment',
success_url: 'http://localhost:3000/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:3000/cancel',
});
// Manually trigger the webhook
await stripe.testHelpers.testClocks.advance(Date.now() + 5000);
});
Be aware: Stripe's test helpers are still relatively new and have limitations depending on your Stripe plan. Polling your own API (the previous pattern) is more portable and closer to what actually happens in production.
Common Mistakes
- Testing with live keys: This creates real charges and breaks your test isolation. Always use test keys for everything except production deployments.
- Not waiting for the iframe to load: Immediately trying to fill Stripe's iframe before it's ready causes random failures. Use explicit waitForFunction or waitForSelector before interacting.
- Assuming redirect means success: A successful redirect to your success URL doesn't guarantee the payment was recorded. Verify through your API that the database state changed.
- Hardcoding test card numbers: Test card numbers are fine to hardcode, but they should be in a constants file or test fixture, not scattered across 50 test files. Update them in one place if Stripe changes them (unlikely but possible).
- Ignoring webhook failures silently: If a webhook fails, your customer is not refunded and your business logic is broken. Log webhook errors prominently and have monitoring alerts on them.
- Testing only the happy path: Declined cards, expired cards, 3D Secure challenges, and network errors all happen in production. Test them.
Key Takeaways
- Use separate test and live Stripe API keys, and inject them via environment configuration, never commit them.
- Test both successful payments (4242 4242 4242 4242) and declined scenarios (4000 0000 0000 0002) to ensure error handling works.
- Interact with Stripe's hosted checkout iframe using Playwright's
frameLocatorAPI, and always wait for the iframe to be present before filling fields. - Verify payment success by polling your own API endpoint or checking database state, not just by checking the redirect URL.
- In CI, either poll your API with retries to wait for webhooks, or use Stripe's test helpers API to trigger webhook events synchronously.
- Document your test card scenarios and organize them in a reusable fixture or constants file so new team members know what each card tests.
- Monitor webhook processing in production with alerts; webhook failures are silent revenue killers.






Comments
Loading comments…