End-to-End Checkout Flow Testing: Playwright Scripts, Edge Cases, and Cross-Browser Payment Forms

End-to-End Checkout Flow Testing: Playwright Scripts, Edge Cases, and Cross-Browser Payment Forms

End-to-end checkout testing with Playwright covers the critical path from cart to order confirmation, including payment form interactions, address validation, edge cases like session expiry, and cross-browser compatibility for payment iframes.


Why Checkout Needs Dedicated E2E Tests

Checkout is the most valuable flow in any e-commerce application. A broken checkout is a direct revenue loss — and it often breaks in ways that unit and integration tests miss:

  • Payment iframes render differently across browsers
  • Address autocomplete and form autofill interfere with test selectors
  • Session tokens expire mid-checkout on slow connections
  • Mobile keyboards shift the viewport and hide the pay button
  • Third-party scripts (analytics, chat widgets) block checkout button clicks

End-to-end tests with Playwright catch these issues because they run against a real browser.


Project Setup

npm install -D @playwright/test
npx playwright install chromium firefox webkit
// playwright.config.js
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  timeout: 60000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? 'github' : 'html',

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    video: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
  ],
});

Core Checkout Flow Test

This is the test you run on every commit. It must always pass.

// tests/e2e/checkout.spec.js
import { test, expect } from '@playwright/test';
import { addToCart, fillShippingAddress, fillPaymentCard } from './helpers/checkout';

test.describe('Checkout flow', () => {
  test.beforeEach(async ({ page }) => {
    // Use a saved auth state if the store requires login
    // await page.context().addCookies([...]); // or use storageState
  });

  test('completes checkout with credit card', async ({ page }) => {
    // Step 1: Add product to cart
    await page.goto('/products/test-product-always-in-stock');
    await page.click('[data-testid="add-to-cart"]');

    // Verify cart update
    await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');

    // Step 2: Proceed to checkout
    await page.goto('/cart');
    await page.click('[data-testid="checkout-button"]');
    await expect(page).toHaveURL(/checkout/);

    // Step 3: Fill shipping address
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="firstName"]', 'Test');
    await page.fill('[name="lastName"]', 'User');
    await page.fill('[name="address1"]', '123 Main Street');
    await page.fill('[name="city"]', 'San Francisco');
    await page.selectOption('[name="provinceCode"]', 'CA');
    await page.fill('[name="zip"]', '94105');
    await page.selectOption('[name="countryCode"]', 'US');

    await page.click('[data-testid="continue-to-shipping"]');

    // Step 4: Select shipping method
    await page.waitForSelector('[data-testid="shipping-method"]');
    await page.click('[data-testid="shipping-method-standard"]');
    await page.click('[data-testid="continue-to-payment"]');

    // Step 5: Enter payment details
    // Stripe card element renders in an iframe
    const cardFrame = page.frameLocator('iframe[title="Secure card payment input frame"]')
      .or(page.frameLocator('iframe[name^="__privateStripeFrame"]'));

    await cardFrame.locator('[name="cardnumber"]').fill('4242424242424242');
    await cardFrame.locator('[name="exp-date"]').fill('12/28');
    await cardFrame.locator('[name="cvc"]').fill('123');

    // Step 6: Verify order summary before placing
    const total = await page.locator('[data-testid="order-total"]').textContent();
    expect(parseFloat(total.replace(/[^0-9.]/g, ''))).toBeGreaterThan(0);

    // Step 7: Place order
    await page.click('[data-testid="place-order"]');

    // Step 8: Verify confirmation page
    await expect(page).toHaveURL(/order-confirmation|thank-you/, { timeout: 15000 });
    await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
    await expect(page.locator('[data-testid="confirmation-email"]')).toContainText('test@example.com');
  });
});

Edge Case Tests

Declined Card

test('shows clear error when card is declined', async ({ page }) => {
  await addToCart(page, '/products/test-product');
  await page.goto('/checkout');
  await fillShippingAddress(page);
  await page.click('[data-testid="continue-to-payment"]');

  await fillPaymentCard(page, '4000000000000002'); // Always declined

  await page.click('[data-testid="place-order"]');

  // Should stay on checkout, show error
  await expect(page).toHaveURL(/checkout/);
  const error = page.locator('[data-testid="payment-error"]');
  await expect(error).toBeVisible();
  await expect(error).toContainText(/declined|card/i);

  // Verify form is still usable (user can retry)
  await expect(page.locator('[data-testid="place-order"]')).toBeEnabled();
});

Insufficient Funds

test('shows specific error for insufficient funds', async ({ page }) => {
  await addToCart(page, '/products/test-product');
  await page.goto('/checkout');
  await fillShippingAddress(page);
  await page.click('[data-testid="continue-to-payment"]');

  await fillPaymentCard(page, '4000000000009995'); // Insufficient funds

  await page.click('[data-testid="place-order"]');

  await expect(page.locator('[data-testid="payment-error"]')).toContainText(
    /insufficient|funds/i
  );
});

Out-of-Stock Race Condition

test('handles item going out of stock during checkout', async ({ page, request }) => {
  // Add item to cart
  await addToCart(page, '/products/limited-stock-item');
  await page.goto('/checkout');
  await fillShippingAddress(page);
  await page.click('[data-testid="continue-to-payment"]');

  // Simulate stock being depleted while user is on payment step
  await request.post('/api/test/deplete-stock', {
    data: { sku: 'limited-stock-item' },
    headers: { 'X-Test-Secret': process.env.TEST_SECRET },
  });

  await fillPaymentCard(page, '4242424242424242');
  await page.click('[data-testid="place-order"]');

  // Should show helpful out-of-stock message, not a generic error
  await expect(page.locator('[data-testid="stock-error"]')).toContainText(
    /no longer available|out of stock/i
  );
});

Session Expiry During Checkout

test('recovers gracefully from expired session', async ({ page, context }) => {
  await addToCart(page, '/products/test-product');
  await page.goto('/checkout');
  await fillShippingAddress(page);

  // Expire the session by clearing auth cookies
  await context.clearCookies();

  await page.click('[data-testid="continue-to-payment"]');

  // Should either redirect to login or show session-expired notice
  const url = page.url();
  const hasLoginRedirect = url.includes('login') || url.includes('sign-in');
  const hasSessionError = await page.locator('[data-testid="session-error"]').isVisible();

  expect(hasLoginRedirect || hasSessionError).toBe(true);
});

Empty Cart Checkout Prevention

test('cannot checkout with empty cart', async ({ page }) => {
  // Go directly to checkout with empty cart
  await page.goto('/checkout');

  // Should redirect to cart or show empty cart message
  const redirectedToCart = page.url().includes('/cart');
  const hasEmptyCartMessage = await page.locator('[data-testid="empty-cart"]').isVisible();

  expect(redirectedToCart || hasEmptyCartMessage).toBe(true);

  // Checkout button should not be present
  await expect(page.locator('[data-testid="place-order"]')).not.toBeVisible();
});

Coupon Code Application

test('applies valid coupon code and recalculates total', async ({ page }) => {
  await addToCart(page, '/products/test-product');
  await page.goto('/cart');

  const originalTotal = await page.locator('[data-testid="cart-total"]').textContent();

  // Apply coupon
  await page.fill('[data-testid="coupon-input"]', 'SAVE10');
  await page.click('[data-testid="apply-coupon"]');

  await expect(page.locator('[data-testid="coupon-success"]')).toBeVisible();
  await expect(page.locator('[data-testid="discount-line"]')).toBeVisible();

  const newTotal = await page.locator('[data-testid="cart-total"]').textContent();
  const originalAmount = parseFloat(originalTotal.replace(/[^0-9.]/g, ''));
  const newAmount = parseFloat(newTotal.replace(/[^0-9.]/g, ''));

  expect(newAmount).toBeLessThan(originalAmount);
});

test('shows error for invalid coupon code', async ({ page }) => {
  await addToCart(page, '/products/test-product');
  await page.goto('/cart');

  await page.fill('[data-testid="coupon-input"]', 'NOTACODE');
  await page.click('[data-testid="apply-coupon"]');

  await expect(page.locator('[data-testid="coupon-error"]')).toContainText(
    /invalid|not found|expired/i
  );
});

Cross-Browser Payment Form Testing

Payment forms (especially Stripe's card element) render differently across browsers. These tests verify consistent behavior.

// tests/e2e/payment-crossbrowser.spec.js
import { test, expect } from '@playwright/test';

// This test runs on all browsers defined in playwright.config.js
test('payment form is functional across browsers', async ({ page, browserName }) => {
  await addToCart(page, '/products/test-product');
  await page.goto('/checkout');
  await fillShippingAddress(page);
  await page.click('[data-testid="continue-to-payment"]');

  // Stripe iframe selector may differ by browser
  const iframeSelectors = [
    'iframe[title="Secure card payment input frame"]',
    'iframe[name^="__privateStripeFrame"]',
    'iframe[src*="stripe.com"]',
  ];

  let cardFrame = null;
  for (const selector of iframeSelectors) {
    const iframe = page.frameLocator(selector);
    const exists = await iframe.locator('[name="cardnumber"]').count();
    if (exists > 0) {
      cardFrame = iframe;
      break;
    }
  }

  expect(cardFrame, `Stripe card frame not found in ${browserName}`).not.toBeNull();

  await cardFrame.locator('[name="cardnumber"]').fill('4242424242424242');
  await cardFrame.locator('[name="exp-date"]').fill('12/28');
  await cardFrame.locator('[name="cvc"]').fill('123');

  // Verify card is accepted before submission
  await expect(cardFrame.locator('[name="cardnumber"]')).toHaveValue(/4242/);

  await page.click('[data-testid="place-order"]');
  await expect(page).toHaveURL(/confirmation|thank-you/, { timeout: 20000 });
});

Mobile-Specific Payment Tests

test('checkout works on mobile with virtual keyboard', async ({ page }) => {
  // This test runs on mobile browser via playwright.config.js devices
  await addToCart(page, '/products/test-product');
  await page.goto('/checkout');

  // Verify mobile layout
  const viewport = page.viewportSize();
  expect(viewport.width).toBeLessThan(600);

  // Verify the checkout form is scrollable and not clipped
  await page.fill('[name="email"]', 'test@example.com');

  // Tap the first name field — on mobile, this triggers the keyboard
  await page.tap('[name="firstName"]');
  await page.fill('[name="firstName"]', 'Test');

  // Verify the continue button is accessible (not hidden behind keyboard)
  const continueBtn = page.locator('[data-testid="continue-to-shipping"]');
  await continueBtn.scrollIntoViewIfNeeded();
  await expect(continueBtn).toBeInViewport();
  await continueBtn.tap();

  await expect(page).toHaveURL(/shipping|payment/);
});

Test Helpers

Create reusable helpers to keep test code DRY:

// tests/e2e/helpers/checkout.js

export async function addToCart(page, productPath) {
  await page.goto(productPath);
  await page.waitForSelector('[data-testid="add-to-cart"]');
  await page.click('[data-testid="add-to-cart"]');
  await page.waitForSelector('[data-testid="cart-count"]');
}

export async function fillShippingAddress(page, opts = {}) {
  const address = {
    email: 'test@example.com',
    firstName: 'Test',
    lastName: 'User',
    address1: '123 Main St',
    city: 'San Francisco',
    state: 'CA',
    zip: '94105',
    country: 'US',
    ...opts,
  };

  await page.fill('[name="email"]', address.email);
  await page.fill('[name="firstName"]', address.firstName);
  await page.fill('[name="lastName"]', address.lastName);
  await page.fill('[name="address1"]', address.address1);
  await page.fill('[name="city"]', address.city);

  const stateSelector = page.locator('[name="provinceCode"], [name="state"]').first();
  await stateSelector.selectOption(address.state);

  await page.fill('[name="zip"]', address.zip);

  const countrySelector = page.locator('[name="countryCode"], [name="country"]').first();
  await countrySelector.selectOption(address.country);
}

export async function fillPaymentCard(page, cardNumber = '4242424242424242') {
  const iframeLocator = page.frameLocator('iframe[title="Secure card payment input frame"]')
    .or(page.frameLocator('iframe[name^="__privateStripeFrame"]'));

  await iframeLocator.locator('[name="cardnumber"]').fill(cardNumber);
  await iframeLocator.locator('[name="exp-date"]').fill('12/28');
  await iframeLocator.locator('[name="cvc"]').fill('123');
}

CI Integration

# .github/workflows/e2e.yml
name: Checkout E2E Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e-checkout:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps
      - name: Start dev server
        run: npm run dev &
        env:
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}
          STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_TEST_PUBLISHABLE_KEY }}
      - name: Wait for server
        run: npx wait-on http://localhost:3000 --timeout 30000
      - name: Run checkout E2E tests
        run: npx playwright test tests/e2e/checkout.spec.js
        env:
          BASE_URL: http://localhost:3000
          TEST_SECRET: ${{ secrets.E2E_TEST_SECRET }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Summary

End-to-end checkout testing with Playwright should cover:

  1. The happy path — successful card payment from cart to confirmation
  2. Payment failures — declined card, insufficient funds, expired card
  3. Edge cases — out-of-stock race conditions, session expiry, empty cart
  4. Cross-browser — Chrome, Firefox, Safari, and mobile browsers
  5. Form interactions — coupon codes, address validation, shipping selection

Keep the test count small — fewer than 20 checkout E2E tests is a sign of good discipline. If you have 50+ checkout E2E tests, most of them should be unit or integration tests instead. Every E2E test you add multiplies your CI time across all browsers in your test matrix.

Read more