End-to-End Payment Flow Testing: Checkout, Subscription, and Refund
Payment flows are among the most critical user journeys in any application. A broken checkout means lost revenue; a broken refund flow means customer support calls. End-to-end payment testing validates the complete flow from clicking "Buy" through webhook processing and database updates.
This guide covers E2E payment testing using Stripe's test mode with Playwright: checkout sessions, subscriptions, refunds, and payment failure scenarios.
Stripe Test Mode Setup
Stripe provides test card numbers that simulate different scenarios without real transactions:
| Card | Scenario |
|---|---|
4242 4242 4242 4242 |
Successful payment |
4000 0000 0000 0002 |
Card declined |
4000 0025 0000 3155 |
3D Secure authentication required |
4000 0000 0000 9995 |
Insufficient funds |
4000 0000 0000 0069 |
Expired card |
All test cards use any future expiry date, any 3-digit CVV, and any 5-digit zip.
Setting Up Playwright for Payment Tests
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'payment-tests',
testMatch: '**/*.payment.spec.ts',
use: {
// Payment tests need more time — Stripe redirects and webhooks
actionTimeout: 30_000,
navigationTimeout: 30_000,
},
},
],
});Testing Stripe Checkout (Hosted Page)
For Stripe Checkout (redirect to Stripe's hosted page):
// e2e/checkout.payment.spec.ts
import { test, expect } from '@playwright/test';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY!, { apiVersion: '2023-10-16' });
test.describe('Stripe Checkout', () => {
test('successful checkout with Visa card', async ({ page }) => {
// Start checkout
await page.goto('/pricing');
await page.click('[data-testid="subscribe-pro-btn"]');
// Redirected to Stripe Checkout
await page.waitForURL(/checkout\.stripe\.com/);
// Fill in payment details on Stripe's hosted page
await page.fill('#email', 'test@helpmetest.com');
await page.fill('[data-testid="cardNumber"]', '4242 4242 4242 4242');
await page.fill('[data-testid="cardExpiry"]', '12 / 26');
await page.fill('[data-testid="cardCvc"]', '123');
await page.fill('[name="billingName"]', 'Test User');
// Submit payment
await page.click('[data-testid="submitButton"]');
// Wait for redirect back to your success page
await page.waitForURL(/\/payment\/success/);
// Verify success UI
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
await expect(page.locator('[data-testid="success-message"]')).toContainText('Payment successful');
});
test('declined card shows error message', async ({ page }) => {
await page.goto('/pricing');
await page.click('[data-testid="subscribe-pro-btn"]');
await page.waitForURL(/checkout\.stripe\.com/);
await page.fill('#email', 'test@helpmetest.com');
await page.fill('[data-testid="cardNumber"]', '4000 0000 0000 0002'); // Declined
await page.fill('[data-testid="cardExpiry"]', '12 / 26');
await page.fill('[data-testid="cardCvc"]', '123');
await page.fill('[name="billingName"]', 'Test User');
await page.click('[data-testid="submitButton"]');
// Stripe shows error on the checkout page
await expect(page.locator('[data-testid="errorMessage"]')).toBeVisible();
await expect(page.locator('[data-testid="errorMessage"]')).toContainText('declined');
});
});Testing Custom Payment Form (Stripe Elements)
For applications using Stripe Elements (embedded in your own form):
// e2e/elements-checkout.payment.spec.ts
import { test, expect, Page } from '@playwright/test';
async function fillStripeCard(page: Page, cardNumber: string) {
// Stripe Elements renders in an iframe
const cardFrame = page.frameLocator('iframe[name*="__privateStripeFrame"]').first();
await cardFrame.locator('[placeholder="Card number"]').fill(cardNumber);
await cardFrame.locator('[placeholder="MM / YY"]').fill('12 / 26');
await cardFrame.locator('[placeholder="CVC"]').fill('123');
await cardFrame.locator('[placeholder="ZIP"]').fill('10001');
}
test.describe('Stripe Elements checkout', () => {
test.beforeEach(async ({ page }) => {
// Log in as an authenticated user
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'testpassword123');
await page.click('[type="submit"]');
await page.waitForURL('/dashboard');
});
test('successful one-time payment', async ({ page }) => {
await page.goto('/shop/product-1');
await page.click('[data-testid="buy-now-btn"]');
// Your custom checkout page
await expect(page).toHaveURL('/checkout');
await fillStripeCard(page, '4242 4242 4242 4242');
await page.click('[data-testid="pay-btn"]');
// Wait for payment processing
await page.waitForURL('/order/confirmation', { timeout: 30_000 });
// Verify order confirmation
await expect(page.locator('[data-testid="order-id"]')).toBeVisible();
await expect(page.locator('[data-testid="order-status"]')).toHaveText('Paid');
});
test('handles 3D Secure authentication', async ({ page }) => {
await page.goto('/checkout');
await fillStripeCard(page, '4000 0025 0000 3155'); // Triggers 3DS
await page.click('[data-testid="pay-btn"]');
// Stripe opens a 3DS authentication modal
const popup = page.frameLocator('iframe[name*="__privateStripeFrame"]').last();
// In test mode, approve the 3DS challenge
await popup.locator('[data-testid="3ds-complete-btn"]').click({ timeout: 15_000 });
// Payment should succeed after 3DS
await page.waitForURL('/order/confirmation', { timeout: 30_000 });
await expect(page.locator('[data-testid="order-status"]')).toHaveText('Paid');
});
test('shows insufficient funds error', async ({ page }) => {
await page.goto('/checkout');
await fillStripeCard(page, '4000 0000 0000 9995');
await page.click('[data-testid="pay-btn"]');
// Error should be displayed on the page
await expect(page.locator('[data-testid="payment-error"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-testid="payment-error"]')).toContainText('insufficient funds');
});
});Testing Subscription Flows
// e2e/subscription.payment.spec.ts
test.describe('Subscription management', () => {
test('creates monthly subscription', async ({ page }) => {
await page.goto('/subscribe');
await page.click('[data-testid="plan-monthly"]');
await fillStripeCard(page, '4242 4242 4242 4242');
await page.click('[data-testid="subscribe-btn"]');
await page.waitForURL('/dashboard', { timeout: 30_000 });
// Verify subscription is active
await expect(page.locator('[data-testid="plan-badge"]')).toHaveText('Pro Monthly');
await expect(page.locator('[data-testid="next-billing-date"]')).toBeVisible();
});
test('cancels subscription and retains access until period end', async ({ page }) => {
// Start from an active subscription
await page.goto('/billing');
const nextBillingDate = await page.locator('[data-testid="next-billing-date"]').textContent();
await page.click('[data-testid="cancel-subscription-btn"]');
// Confirm cancellation dialog
await page.click('[data-testid="confirm-cancel-btn"]');
// Subscription should be "canceling" not "canceled"
await expect(page.locator('[data-testid="subscription-status"]')).toHaveText('Canceling');
await expect(page.locator('[data-testid="access-until"]')).toContainText(nextBillingDate!);
// User still has access
await page.goto('/dashboard');
await expect(page).toHaveURL('/dashboard'); // Not redirected to upgrade page
});
});Testing Refunds
// e2e/refunds.payment.spec.ts
test.describe('Refund flow', () => {
test('admin can refund a payment', async ({ page }) => {
// Log in as admin
await loginAs(page, 'admin@example.com');
// Find a paid order
await page.goto('/admin/orders');
await page.click('[data-testid="order-row"]:first-child [data-testid="refund-btn"]');
// Confirm refund dialog
await expect(page.locator('[data-testid="refund-dialog"]')).toBeVisible();
await page.fill('[data-testid="refund-amount"]', '29.99');
await page.click('[data-testid="confirm-refund-btn"]');
// Verify refund status
await expect(page.locator('[data-testid="order-status"]').first()).toHaveText('Refunded');
});
test('customer sees refund pending in order history', async ({ page }) => {
await loginAs(page, 'customer@example.com');
await page.goto('/orders');
// After admin issues refund
const refundedOrder = page.locator('[data-testid="order-refunded"]').first();
await expect(refundedOrder).toBeVisible();
await expect(refundedOrder.locator('[data-testid="refund-amount"]')).toContainText('$29.99');
});
});Verifying Webhook Processing
After a payment succeeds, webhooks update your database. Verify the downstream effects:
// e2e/webhook-effects.payment.spec.ts
import { waitFor } from '../test-utils/wait';
test('database is updated after payment webhook', async ({ page, request }) => {
// Complete a payment
await page.goto('/checkout');
await fillStripeCard(page, '4242 4242 4242 4242');
await page.click('[data-testid="pay-btn"]');
await page.waitForURL('/order/confirmation');
const orderId = await page.locator('[data-testid="order-id"]').textContent();
// Poll the API until the order is marked as paid (webhook may take 1-2 seconds)
await waitFor(async () => {
const res = await request.get(`/api/orders/${orderId}`);
const order = await res.json();
return order.status === 'paid';
}, { timeout: 10_000, interval: 500 });
// Verify the webhook updated the order
const res = await request.get(`/api/orders/${orderId}`);
const order = await res.json();
expect(order.status).toBe('paid');
expect(order.paidAt).toBeTruthy();
expect(order.stripePaymentIntentId).toMatch(/^pi_/);
});Summary
End-to-end payment testing with Playwright and Stripe test mode:
- Use Stripe test cards —
4242 4242 4242 4242for success,4000 0000 0000 0002for decline - Handle Stripe iframes — Stripe Elements renders in iframes, use
frameLocatorto interact - Test the full flow — not just the checkout page, but the confirmation, email receipt, and database update
- Test webhook side effects — poll until webhook-triggered updates appear
- Cover all scenarios — success, declined, 3DS, insufficient funds, subscription cancel, refund
Payment bugs are silent killers — users don't always report them, but you lose revenue and trust. Automated E2E payment tests running in CI and against production (in test mode) catch regressions before customers do.