Payments Testing Guide
How to Test Stripe Checkout End to End: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing Stripe Checkout with Playwright. Happy path, 3D Secure, declined cards, subscriptions with coupons, webhook-driven state, mobile wallets, and the iframe pitfalls that break real suites.
“Stripe processes an enormous amount of payment volume each year, and the Checkout hosted page handles the long tail of payment methods, SCA, and tax for the majority of Stripe customers.”
1. Why Testing Stripe Checkout Is Harder Than It Looks
Stripe Checkout is a hosted page. When your user clicks your pay button, the browser navigates away from your domain to checkout.stripe.com, collects payment details, runs any required 3D Secure challenge, and then redirects back to your success URL. That cross-origin hop is where the majority of test suites fall apart. Playwright can drive Stripe Checkout, but only if your test understands that it is driving two different domains separated by a full-page navigation, and that the card input fields live inside Stripe-hosted iframes that you do not control.
There are four structural reasons this flow is hard to test reliably. First, the card number, expiry, CVC, and postal code fields are each rendered in a separate iframe for PCI compliance. You cannot reach them with ordinary page locators; you have to cross a frame boundary. Second, the 3D Secure challenge opens another iframe nested inside the Stripe iframe, loaded from a bank or Stripe ACS domain. Third, Stripe updates the Checkout UI regularly, so CSS-based selectors that worked last month may silently break. Fourth, the final state of a payment is driven by webhooks, not by what the browser sees. A user can be redirected to your success page before the charge is actually marked as succeeded in your database.
A good Stripe Checkout test suite covers all four of these surfaces. The sections below walk through each scenario you need, with runnable Playwright TypeScript code you can copy directly.
2. Setting Up a Reliable Test Environment
Before you write a single scenario, get the environment right. Stripe provides a full test mode that uses separate API keys and accepts a documented set of test card numbers. Run every test against test mode. Never run end-to-end tests against live keys.
Environment Variables
# .env.test
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...
APP_BASE_URL=http://localhost:3000
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/checkout/success
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/checkout/cancelForwarding Webhooks Locally
Stripe events reach your backend through webhooks, and your tests depend on those events firing. Use the Stripe CLI to forward test mode events to your local server while the test run is active.
# Terminal 1: your app
npm run dev
# Terminal 2: forward webhooks
stripe listen --forward-to localhost:3000/api/stripe/webhook \
--events checkout.session.completed,payment_intent.succeeded,\
invoice.paid,customer.subscription.created
# Terminal 3: run Playwright
npx playwright testThe Test Card Matrix You Actually Need
Stripe publishes dozens of test cards. For end-to-end coverage you only need a handful. Memorize these or put them in a fixtures file.
| Number | Behavior |
|---|---|
| 4242 4242 4242 4242 | Succeeds, no authentication required |
| 4000 0025 0000 3155 | Requires 3D Secure, succeeds on approve |
| 4000 0000 0000 9995 | Declined: insufficient funds |
| 4000 0000 0000 0002 | Declined: generic card decline |
| 4000 0000 0000 0069 | Declined: expired card |
| 4000 0084 0000 1629 | 3D Secure challenge, then decline after auth |
Use any future expiry date (for example 12/34), any three digit CVC, and any valid postal code (for example 42424). Stripe does not validate these beyond format in test mode.
3. Scenario: Happy Path Card Payment
The first scenario every Stripe integration needs is the one-shot card payment that succeeds without any authentication challenge. This is your smoke test. If this breaks, your pricing page is broken and you want to know in the next five minutes.
Goal
Starting from your pricing page, complete a full Stripe Checkout purchase with a US card, land on the success page, and confirm the order record exists in your database.
Preconditions
- App running at
APP_BASE_URLin test mode - Stripe CLI forwarding webhooks
- At least one product configured in Stripe test mode
- A fresh test user email for each run to keep assertions idempotent
Playwright Implementation
import { test, expect } from '@playwright/test';
test('happy path: one-time card payment succeeds', async ({ page }) => {
const email = `test+${Date.now()}@assrt.ai`;
// 1. Start on your pricing page and click the primary CTA
await page.goto('/pricing');
await page.getByRole('button', { name: 'Buy Pro plan' }).click();
// 2. Wait for the cross-domain navigation to Stripe Checkout
await page.waitForURL(/checkout\.stripe\.com/);
// 3. Fill the email field (top-level, not in an iframe)
await page.getByLabel('Email').fill(email);
// 4. Card fields live in separate iframes, one per field.
// Use frameLocator to reach inside.
const cardFrame = page.frameLocator(
'iframe[name^="__privateStripeFrame"][title*="card number"]'
);
await cardFrame.getByPlaceholder('1234 1234 1234 1234')
.fill('4242 4242 4242 4242');
const expiryFrame = page.frameLocator(
'iframe[name^="__privateStripeFrame"][title*="expiration"]'
);
await expiryFrame.getByPlaceholder('MM / YY').fill('12 / 34');
const cvcFrame = page.frameLocator(
'iframe[name^="__privateStripeFrame"][title*="CVC"]'
);
await cvcFrame.getByPlaceholder('CVC').fill('123');
// 5. Name, country, postal code
await page.getByLabel('Cardholder name').fill('Jane Tester');
await page.getByLabel('ZIP').fill('42424');
// 6. Submit
await page.getByTestId('hosted-payment-submit-button').click();
// 7. Wait for redirect back to your success URL
await page.waitForURL(/\/checkout\/success/, { timeout: 30_000 });
// 8. Assert the success UI
await expect(page.getByRole('heading', { name: /thank you/i }))
.toBeVisible();
await expect(page.getByText(email)).toBeVisible();
});What to Assert Beyond the UI
The success page is necessary but not sufficient. A user can hit the success URL while your webhook handler is still processing, which means your database row may not yet reflect a completed charge. A robust happy path test also polls your own API for the order status or subscribes to the webhook event directly.
// After the UI assertions, poll your own API
await expect.poll(async () => {
const res = await request.get(`/api/orders?email=${email}`);
const body = await res.json();
return body.status;
}, { timeout: 10_000 }).toBe('paid');4. Scenario: 3D Secure Authentication
Strong Customer Authentication (SCA) is mandatory for most European cards and increasingly common elsewhere. Stripe handles the challenge UI, but your test has to reach into a second layer of nested iframes and click the approve button. This is the scenario that most hand-written test suites get wrong.
The Nested Iframe Structure
When you submit a 3DS test card, Stripe opens a challenge modal. That modal is an iframe loaded from hooks.stripe.com, and inside that iframe is another iframe that renders the actual bank approve and fail buttons. You need two frameLocatorcalls chained together.
test('3D Secure challenge: user approves', async ({ page }) => {
await page.goto('/pricing');
await page.getByRole('button', { name: 'Buy Pro plan' }).click();
await page.waitForURL(/checkout\.stripe\.com/);
await page.getByLabel('Email').fill('3ds+test@assrt.ai');
// Fill card fields with the 3DS-required test card
await page.frameLocator('iframe[title*="card number"]')
.getByPlaceholder('1234 1234 1234 1234')
.fill('4000 0025 0000 3155');
await page.frameLocator('iframe[title*="expiration"]')
.getByPlaceholder('MM / YY').fill('12 / 34');
await page.frameLocator('iframe[title*="CVC"]')
.getByPlaceholder('CVC').fill('123');
await page.getByLabel('Cardholder name').fill('3DS Tester');
await page.getByLabel('ZIP').fill('42424');
await page.getByTestId('hosted-payment-submit-button').click();
// The 3DS challenge iframe is nested inside another iframe.
// Chain frameLocator twice.
const challengeOuter = page.frameLocator(
'iframe[name^="__privateStripeFrame"][src*="challenge"]'
);
const challengeInner = challengeOuter.frameLocator(
'iframe[name="acsFrame"]'
);
// The approve button is labeled "Complete authentication" in test mode
await challengeInner
.getByRole('button', { name: /complete authentication/i })
.click();
await page.waitForURL(/\/checkout\/success/, { timeout: 30_000 });
await expect(page.getByRole('heading', { name: /thank you/i }))
.toBeVisible();
});Also Test the Fail Path
Test mode exposes a matching Fail authentication button inside the same ACS frame. Write a second test that clicks it and asserts your app shows the correct user-facing error and does not create an order. This is the difference between a working 3DS integration and one that silently swallows failed authentications.
5. Scenario: Declined and Failed Cards
Every payments integration claims to handle declines. Very few actually show a useful error, keep the session alive, and let the user try a different card. The only way to prove your flow does all three is to test each decline code.
The pattern is identical for each decline card. Submit, wait for the inline error inside the Stripe payment form, assert the message, and then verify the user is still on the Checkout page and no webhook was fired for a successful charge.
const declineCards = [
{ number: '4000000000009995', code: 'insufficient_funds',
message: /insufficient funds/i },
{ number: '4000000000000002', code: 'generic_decline',
message: /card was declined/i },
{ number: '4000000000000069', code: 'expired_card',
message: /expired/i },
];
for (const card of declineCards) {
test(`declined card: ${card.code}`, async ({ page }) => {
await page.goto('/pricing');
await page.getByRole('button', { name: 'Buy Pro plan' }).click();
await page.waitForURL(/checkout\.stripe\.com/);
await page.getByLabel('Email').fill(`${card.code}@assrt.ai`);
await page.frameLocator('iframe[title*="card number"]')
.getByPlaceholder('1234 1234 1234 1234').fill(card.number);
await page.frameLocator('iframe[title*="expiration"]')
.getByPlaceholder('MM / YY').fill('12 / 34');
await page.frameLocator('iframe[title*="CVC"]')
.getByPlaceholder('CVC').fill('123');
await page.getByLabel('Cardholder name').fill('Decline Tester');
await page.getByLabel('ZIP').fill('42424');
await page.getByTestId('hosted-payment-submit-button').click();
// Error renders in the top-level Checkout page, not in an iframe
await expect(page.getByText(card.message)).toBeVisible({ timeout: 15_000 });
// Assert we did NOT navigate to success
await expect(page).toHaveURL(/checkout\.stripe\.com/);
});
}Parameterizing the test this way keeps your suite compact and makes it trivial to add a new decline code when Stripe publishes one. Each iteration runs in parallel on modern Playwright configurations, so the whole decline matrix completes in under a minute.
6. Scenario: Subscriptions, Trials, and Coupons
Subscription Checkout sessions share most of the happy path flow but add three things you need to assert: the trial period is applied, the coupon reduces the displayed total correctly, and the first invoice is either zero (trial) or the discounted amount (coupon without trial).
test('subscription signup with coupon and 14 day trial', async ({ page }) => {
await page.goto('/pricing');
await page.getByRole('button', { name: 'Start Pro trial' }).click();
await page.waitForURL(/checkout\.stripe\.com/);
// Apply a promotion code before paying
await page.getByRole('button', { name: /add promotion code/i }).click();
await page.getByPlaceholder('Add promotion code').fill('LAUNCH20');
await page.getByRole('button', { name: 'Apply' }).click();
// Assert the 20% discount line item appears
await expect(page.getByText(/LAUNCH20/)).toBeVisible();
await expect(page.getByText(/-\$/)).toBeVisible();
// Trial should show "Due today: $0.00"
await expect(page.getByText(/due today/i)).toBeVisible();
await expect(page.getByText('$0.00')).toBeVisible();
// Fill card and submit (card still required even for $0 trial)
await page.getByLabel('Email').fill(`sub+${Date.now()}@assrt.ai`);
await page.frameLocator('iframe[title*="card number"]')
.getByPlaceholder('1234 1234 1234 1234')
.fill('4242 4242 4242 4242');
await page.frameLocator('iframe[title*="expiration"]')
.getByPlaceholder('MM / YY').fill('12 / 34');
await page.frameLocator('iframe[title*="CVC"]')
.getByPlaceholder('CVC').fill('123');
await page.getByLabel('Cardholder name').fill('Trial User');
await page.getByLabel('ZIP').fill('42424');
await page.getByTestId('hosted-payment-submit-button').click();
await page.waitForURL(/\/checkout\/success/);
await expect(page.getByText(/trial ends/i)).toBeVisible();
});For subscription tests, also verify the customer.subscription.created webhook fired with the expected trial_end timestamp and discount object. Stripe CLI logs make this easy to check during development, but in CI you want a programmatic assertion against your own database once the webhook handler has written the subscription row.
7. Scenario: Webhook-Driven State Transitions
The browser-driven tests above prove the user-visible flow works, but Stripe Checkout is fundamentally asynchronous. The redirect back to your success URL happens before your webhook handler processes the checkout.session.completed event, which is the event that should actually provision the user. If you treat the redirect as the source of truth, your suite will go green while customers who pay never get access.
The correct pattern is to assert two things: the UI shows the pending state immediately after redirect, and the fulfilled state once the webhook has been processed. Poll your own API for the transition.
test('webhook transitions order from pending to paid', async ({ page, request }) => {
const email = `webhook+${Date.now()}@assrt.ai`;
// ... complete checkout flow with 4242 card ...
await page.waitForURL(/\/checkout\/success/);
// Immediately after redirect: UI may still say "Processing"
const immediate = await request.get(`/api/orders?email=${email}`);
const immediateBody = await immediate.json();
expect(['pending', 'paid']).toContain(immediateBody.status);
// Poll until the webhook has flipped us to paid
await expect.poll(async () => {
const res = await request.get(`/api/orders?email=${email}`);
const body = await res.json();
return body.status;
}, {
timeout: 20_000,
intervals: [500, 1000, 2000],
}).toBe('paid');
// And the UI updates as well (assumes SWR or polling on the success page)
await expect(page.getByText(/order confirmed/i)).toBeVisible();
});This is the single most valuable payments test you can write. It catches every variant of the bug where the redirect works but fulfillment silently fails, which in our experience is the failure mode that costs real revenue.
8. Scenario: Mobile Viewport, Apple Pay, Google Pay
On mobile, Stripe Checkout automatically surfaces Apple Pay or Google Pay when the browser advertises support. In Playwright test mode, you can test two things: that the payment request button renders at all on a mobile viewport, and that the fallback card form still works for users who tap past the wallet.
test.use({
viewport: { width: 390, height: 844 }, // iPhone 14 Pro
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' +
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
});
test('mobile viewport: card form is usable and submit reachable', async ({ page }) => {
await page.goto('/pricing');
await page.getByRole('button', { name: /buy pro/i }).click();
await page.waitForURL(/checkout\.stripe\.com/);
// The payment request button may or may not appear depending on
// Playwright's wallet emulation. Do not assert it is visible; assert
// that if it is absent, the normal card form is reachable.
const cardNumber = page.frameLocator('iframe[title*="card number"]')
.getByPlaceholder('1234 1234 1234 1234');
await expect(cardNumber).toBeVisible({ timeout: 15_000 });
// Submit button must be reachable without horizontal scroll
const submit = page.getByTestId('hosted-payment-submit-button');
await expect(submit).toBeVisible();
const box = await submit.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
});True Apple Pay and Google Pay end-to-end testing requires device integration because the wallet sheet is rendered by the operating system, not the browser. Cover that path with manual smoke tests on a real device before each release. Cover everything else with the scenarios above.
9. Common Pitfalls That Break Real Test Suites
Hard-Coded Iframe Selectors
Every few months Stripe rotates the exact name attribute on the card input iframes. Tests that match on iframe[name="__privateStripeFrame4"]will break overnight. Always match on the stable parts: the name^= prefix and the human-readable title attribute.
Fixed waitForTimeout Calls
If you see await page.waitForTimeout(5000) anywhere in your Stripe tests, delete it. Stripe Checkout loads at wildly variable speeds under CI parallelism. Replace every sleep with an auto-retrying assertion like toBeVisible or a waitForURL that matches the expected next state.
Reusing the Same Email Across Runs
Stripe creates a Customer object on first checkout with a given email. If your tests reuse test@assrt.ai, every run builds on the customer and subscription state from the previous run, and you get heisenbugs that only reproduce on the third run of the day. Always use \`test+\$1775578110464@assrt.ai\` or a UUID.
Not Cleaning Up Subscriptions
Every subscription test creates a real subscription in your Stripe test account. These accumulate. Stripe test mode has limits, and more importantly, the clutter makes debugging a real failure painful. Add a teardown hook that cancels subscriptions created by the test run via the Stripe API, keyed by a metadata tag like metadata.e2e_run="true".
Trusting the Redirect
We covered this in Section 7 but it is worth repeating. The redirect to your success URL is not confirmation of payment. The webhook is. If your tests do not wait for the webhook, your tests do not actually verify that payment succeeded.
10. Writing These Scenarios in Plain English with Assrt
Every scenario above is 40 to 80 lines of Playwright. Multiply that by the ten scenarios you actually need and you have a 500-line file that ships a silent regression the first time Stripe changes an iframe title. Assrt lets you describe the scenario in plain English, generates the equivalent Playwright code, and regenerates the selectors automatically when the underlying page changes.
The happy path scenario from Section 3 looks like this in Assrt:
# scenarios/stripe-happy-path.assrt
describe: Happy path Stripe Checkout with a US card
given:
- I am on the pricing page
- I use a fresh random email
steps:
- click "Buy Pro plan"
- wait for the Stripe Checkout page to load
- fill in the email field
- fill the card number with 4242 4242 4242 4242
- fill the expiry with 12 / 34
- fill the CVC with 123
- fill the cardholder name with "Jane Tester"
- fill the ZIP with 42424
- click the pay button
expect:
- I am redirected to /checkout/success within 30 seconds
- the page shows a thank you heading
- the order API returns "paid" status for my email within 20 secondsAssrt compiles that file into the same Playwright TypeScript you saw in Section 3, committed to your repo as a real test you can read, run, and modify. When Stripe renames an iframe or relabels a button, Assrt detects the failure, analyzes the new DOM, and opens a pull request with the updated locator. Your scenario file stays untouched. This is the failure mode the rest of this guide exists to prevent, and it is the reason we built Assrt.
Start with the happy path scenario above. Once it is green in your CI, add 3D Secure, then the decline matrix, then subscriptions, then the webhook poll, then mobile. In a single afternoon you can have the full ten-scenario Stripe Checkout coverage that most production SaaS companies never manage to ship by hand.
Related Guides
How to Test Apple Pay on Web
A practical, scenario-by-scenario guide to testing Apple Pay on the web with Playwright....
How to Test BigCommerce Checkout
A scenario-by-scenario guide to testing BigCommerce checkout with Playwright. Covers...
How to Test Google Pay on Web
Step-by-step guide to testing Google Pay web integration with Playwright. Covers Payment...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.