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:
- The happy path — successful card payment from cart to confirmation
- Payment failures — declined card, insufficient funds, expired card
- Edge cases — out-of-stock race conditions, session expiry, empty cart
- Cross-browser — Chrome, Firefox, Safari, and mobile browsers
- 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.