End-to-End Payment Flow Testing: Checkout, Subscription, and Refund

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 cards4242 4242 4242 4242 for success, 4000 0000 0000 0002 for decline
  • Handle Stripe iframes — Stripe Elements renders in iframes, use frameLocator to 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.

Read more