Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 052 / 100

Testing e-commerce checkout: what developers should know

The most important flow to protect.

Expona Admin9 min read

The checkout flow is where revenue happens—and where bugs cost money. Testing e-commerce checkout: what developers should know isn't just about catching errors; it's about protecting the most critical path in your application. In this article, we'll walk through a production-grade approach to testing checkout with Playwright, covering cart state, form validation, payment processing, and order confirmation.

Why checkout testing matters

A broken cart increases abandonment rates. A payment integration that fails intermittently erodes trust. A confirmation email that never arrives creates support tickets and refund requests. Unlike other features, checkout failures have immediate financial impact. We've seen teams ship checkout code that works locally but fails under the SSL certificate chain used by their payment processor in production, or validation logic that doesn't handle all the address formats their customers use.

Playwright's ability to intercept network requests, fill forms dynamically, and wait for async operations makes it ideal for checkout testing. Let's build a reliable test suite from first principles.

Add to cart — Assert cart count and totals

The foundation of a checkout flow is a working cart. You need to verify not just that items appear, but that quantities, prices, and totals calculate correctly.

typescript

import { test, expect } from '@playwright/test';

test('add product to cart and verify totals', async ({ page }) => {
  // Navigate to product page
  await page.goto('https://shop.example.com/products/widget-pro');
  
  // Get initial cart count
  const initialCartBadge = await page.locator('[data-testid="cart-count"]').textContent();
  const initialCount = parseInt(initialCartBadge || '0');
  
  // Add to cart with quantity
  await page.locator('[data-testid="quantity-input"]').fill('2');
  await page.locator('button:has-text("Add to Cart")').click();
  
  // Wait for cart update and verify count
  await page.waitForTimeout(500); // Wait for badge animation
  const updatedCartBadge = await page.locator('[data-testid="cart-count"]').textContent();
  const updatedCount = parseInt(updatedCartBadge || '0');
  
  expect(updatedCount).toBe(initialCount + 2);
  
  // Navigate to cart and verify line item
  await page.goto('https://shop.example.com/cart');
  
  const cartItem = page.locator('[data-testid="cart-item-widget-pro"]');
  await expect(cartItem).toBeVisible();
  
  const quantityInCart = await cartItem.locator('[data-testid="quantity"]').inputValue();
  expect(quantityInCart).toBe('2');
  
  // Verify price calculations
  const unitPrice = 49.99;
  const expectedLineTotal = unitPrice * 2;
  const displayedLineTotal = await cartItem.locator('[data-testid="line-total"]').textContent();
  
  expect(displayedLineTotal).toContain('99.98');
  
  // Verify cart subtotal
  const subtotal = await page.locator('[data-testid="subtotal"]').textContent();
  expect(subtotal).toContain('99.98');
});

Key assertions here:

  • Cart badge increments correctly
  • Quantity persists across navigation
  • Line-item totals calculate (unit price × quantity)
  • Cart subtotal sums all line items

We often see teams skip this step and jump straight to checkout testing. That's a mistake. If your cart state is wrong, every test downstream becomes unreliable.

Checkout form — Address, shipping and validation errors

The checkout form is where most user errors occur. Address fields are tricky: they vary by country, some require postal codes, some have different terminology. Shipping methods can be unavailable for certain postcodes. You need to test both the happy path and error states.

typescript

test('checkout form with address validation and shipping selection', async ({ page }) => {
  await page.goto('https://shop.example.com/checkout');
  
  // Fill billing address
  await page.locator('[data-testid="billing-first-name"]').fill('Alice');
  await page.locator('[data-testid="billing-last-name"]').fill('Chen');
  await page.locator('[data-testid="billing-street"]').fill('123 Main St');
  await page.locator('[data-testid="billing-city"]').fill('San Francisco');
  
  // Select state from dropdown
  await page.locator('[data-testid="billing-state"]').selectOption('CA');
  await page.locator('[data-testid="billing-zip"]').fill('94102');
  
  // Verify postal code format validation
  await page.locator('[data-testid="billing-zip"]').clear();
  await page.locator('[data-testid="billing-zip"]').fill('invalid');
  await page.locator('button:has-text("Continue")').click();
  
  // Expect validation error
  const zipError = await page.locator('[data-testid="error-billing-zip"]').textContent();
  expect(zipError).toContain('Valid US ZIP');
  
  // Correct the ZIP
  await page.locator('[data-testid="billing-zip"]').clear();
  await page.locator('[data-testid="billing-zip"]').fill('94102');
  
  // Check "same as billing"
  await page.locator('[data-testid="shipping-same-as-billing"]').check();
  
  // Continue to shipping method
  await page.locator('button:has-text("Continue")').click();
  
  // Wait for shipping options to load
  await page.waitForSelector('[data-testid="shipping-method-option"]');
  
  // Select standard shipping
  const standardShipping = page.locator('[data-testid="shipping-method-standard"]');
  await expect(standardShipping).toContainText('5-7 business days');
  await standardShipping.click();
  
  // Verify shipping cost is applied
  const shippingCost = await page.locator('[data-testid="shipping-cost"]').textContent();
  expect(shippingCost).toContain('9.99');
  
  // Verify total is updated
  const total = await page.locator('[data-testid="order-total"]').textContent();
  expect(total).toContain('109.97'); // subtotal 99.98 + shipping 9.99
});

Important patterns:

  • Test validation rules before submission (ZIP format, required fields)
  • Use data-testid attributes for reliable locators—never rely on CSS classes that designers might change
  • Wait explicitly for async operations (shipping methods loading after address selection)
  • Verify that totals recalculate when shipping method changes

Address validation in particular is a source of hidden bugs. We've seen cases where international addresses fail validation because the backend expects a specific format, but the frontend doesn't enforce it. Test with real addresses from your target markets.

Payments — Use the provider's test cards and iframe locators

Payment processing is where checkout testing becomes delicate. You need test credentials from your payment provider (Stripe, PayPal, Square, etc.). Never, ever use real payment information in tests. Each provider supplies test card numbers that trigger specific responses.

The trickiest part: payment forms often live in iframes. Playwright can interact with iframes, but the syntax is different.

typescript

test('process payment with Stripe test card', async ({ page }) => {
  // Assume we're on the payment page after address and shipping
  await page.goto('https://shop.example.com/checkout/payment');
  
  // Wait for Stripe form to load
  await page.waitForSelector('[data-testid="stripe-container"]');
  
  // Find the iframe
  const stripeFrame = page.frameLocator('[data-testid="stripe-container"] iframe');
  
  // Fill card number in iframe
  const cardNumberInput = stripeFrame.locator('input[name="cardnumber"]');
  await cardNumberInput.fill('4242424242424242'); // Stripe test card
  
  // Fill expiry
  const expiryInput = stripeFrame.locator('input[name="exp-date"]');
  await expiryInput.fill('12/26');
  
  // Fill CVC
  const cvcInput = stripeFrame.locator('input[name="cvc"]');
  await cvcInput.fill('123');
  
  // Fill billing postal
  const postalInput = stripeFrame.locator('input[name="postal"]');
  await postalInput.fill('94102');
  
  // Submit payment
  await page.locator('button:has-text("Pay Now")').click();
  
  // Wait for payment processing
  await page.waitForURL('**/checkout/confirmation**', { timeout: 10000 });
  
  // Verify we reached confirmation page
  const confirmationHeading = page.locator('h1:has-text("Order Confirmed")');
  await expect(confirmationHeading).toBeVisible();
});

Critical details:

  • Use test card numbers only. Stripe: 4242424242424242 for success, 4000000000000002 for decline, etc.
  • Frame locators are different. Use frameLocator() for iframes, not locator()
  • Payment processing takes time. Use waitForURL() to wait for the redirect, not an arbitrary timeout
  • Test declined cards. Use your provider's test cards that trigger failure states, not just success

A common mistake: assuming that because a test passes locally, payment processing works in staging. Payment providers might have different certificate chains, API keys, or rate limits between environments. Run this test against your actual staging environment, not just locally.

Order confirmation — Verify order number and confirmation email

After payment succeeds, you need to verify that an order was actually created. This means checking both the UI and, ideally, the backend state.

typescript

test('verify order confirmation and email delivery', async ({ page, context }) => {
  // Complete checkout flow (abbreviated)
  await page.goto('https://shop.example.com/checkout');
  // ... fill address, shipping, payment ...
  await page.locator('button:has-text("Pay Now")').click();
  
  // Wait for confirmation page
  await page.waitForURL('**/checkout/confirmation**');
  
  // Extract order number from page
  const orderNumberElement = await page.locator('[data-testid="order-number"]').textContent();
  const orderNumber = orderNumberElement?.match(/(\d{6,})/)?.[0];
  
  expect(orderNumber).toBeTruthy();
  
  // Verify confirmation details
  const orderTotal = await page.locator('[data-testid="order-total"]').textContent();
  expect(orderTotal).toContain('109.97');
  
  const customerEmail = await page.locator('[data-testid="confirmation-email"]').textContent();
  expect(customerEmail).toContain('alice@example.com');
  
  // Verify order items
  const orderItems = page.locator('[data-testid="confirmation-item"]');
  await expect(orderItems).toHaveCount(1);
  
  const itemDescription = await orderItems.first().textContent();
  expect(itemDescription).toContain('Widget Pro');
  expect(itemDescription).toContain('Qty: 2');
  
  // Optional: verify order exists in backend via API
  const response = await context.request.get(
    `https://api.example.com/orders/${orderNumber}`,
    {
      headers: {
        'Authorization': `Bearer ${process.env.TEST_API_TOKEN}`
      }
    }
  );
  
  expect(response.status()).toBe(200);
  const order = await response.json();
  expect(order.status).toBe('pending'); // or 'confirmed' depending on your flow
  expect(order.subtotal).toBe(99.98);
  expect(order.shipping).toBe(9.99);
  expect(order.total).toBe(109.97);
});

What we're verifying:

  • Order number is displayed and extractable
  • Totals match what was displayed during checkout
  • Items and quantities are correct
  • Order exists in the backend with correct data
  • Customer email is captured correctly

Regarding confirmation emails: testing email delivery is out of scope for UI tests. What you can do is verify that your backend system triggered the email send (via logs or a test database). Some teams use services like Mailhog or Mailtrap for development and staging to actually capture emails and verify content programmatically, but that's a separate concern from Playwright.

Common mistakes

We've debugged checkout tests across dozens of projects. Here are the patterns that consistently cause problems:

  • Hardcoding wait times. Don't use `await page.waitForTimeout(2000)`. Wait for specific elements or network activity instead.
  • Not testing internationally. Your US billing address validation might pass, but a UK postcode or Indian PIN code might fail. Use real addresses from your markets.
  • Ignoring iframe boundaries. If your payment form is in an iframe and you use the wrong locator syntax, your test silently fails. Always verify iframe interaction works before relying on it.
  • Assuming payment test cards work everywhere. Stripe test cards work with Stripe. If you're using a different provider or a third-party integration, use their test credentials instead.
  • Not verifying backend state. A test that only checks the UI might miss order creation failures. Always cross-check with the API or database where possible.
  • Testing only the happy path. Run tests with declined cards, invalid addresses, unavailable shipping methods. Error handling is part of checkout.
  • Flaky timeouts for payment processing. Payment APIs can be slow. Use a 10-15 second timeout for payment operations, not 5 seconds.

Key takeaways

  • Cart state is foundational. Verify quantities, prices, and totals before moving to checkout.
  • Test form validation thoroughly: required fields, format validation, error messages, and edge cases like international addresses.
  • Use your payment provider's test cards and credentials. Never use real card data in tests, and always run payment tests against staging, not just locally.
  • Frame locators for iframes differ from regular locators. Use `frameLocator()` and test the pattern before relying on it in production tests.
  • Verify both the UI confirmation and backend order state. A successful payment page doesn't guarantee an order was created.
  • Test failure paths: declined cards, validation errors, timeout scenarios. Real users will encounter these states.
  • Wait for async operations explicitly rather than using arbitrary delays. This makes tests faster and more reliable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles