E-commerce Testing Strategy: The Test Pyramid for Online Stores
A robust e-commerce testing strategy layers unit tests for business logic, integration tests for payment and inventory APIs, and end-to-end tests for critical user journeys like checkout. This guide walks through the test pyramid applied to online stores.
Why E-commerce Testing Is Different
E-commerce applications are among the most demanding to test because failures directly cost money. A broken checkout costs revenue per minute. A mis-priced item causes fraud or customer complaints. An inventory sync failure leads to overselling.
Unlike typical CRUD apps, e-commerce systems require testing across several interconnected domains:
- Cart logic — adding items, quantities, coupons, tax calculations
- Checkout flow — address validation, shipping selection, payment processing
- Payment systems — gateway integration, retry logic, refunds, webhooks
- Inventory — stock tracking, reservation, oversell prevention
- Order management — state machines (pending → paid → shipped → delivered → refunded)
The test pyramid gives you a structure for covering all of this efficiently.
The E-commerce Test Pyramid
/\
/E2E\ ← checkout flows, critical paths (few)
/------\
/ Integ \ ← payment APIs, inventory sync, webhooks (moderate)
/------------\
/ Unit \ ← cart math, pricing, discounts, validators (many)
/----------------\Unit tests are fast and cheap — run in milliseconds with no external dependencies. Integration tests verify your code works with real databases, message queues, and third-party APIs (with controlled test accounts). End-to-end tests drive a real browser through actual user flows, and are slow but catch regressions that lower layers miss.
For e-commerce, a practical ratio is roughly:
- 70% unit tests
- 20% integration tests
- 10% E2E tests
Unit Testing E-commerce Logic
Cart Calculations
Cart math is pure business logic with no side effects — ideal for unit tests.
// cart.test.js
import { calculateTotal, applyDiscount, calculateTax } from '../cart';
describe('calculateTotal', () => {
it('sums item prices with quantities', () => {
const items = [
{ price: 29.99, quantity: 2 },
{ price: 9.99, quantity: 1 },
];
expect(calculateTotal(items)).toBe(69.97);
});
it('returns 0 for empty cart', () => {
expect(calculateTotal([])).toBe(0);
});
});
describe('applyDiscount', () => {
it('applies percentage discount', () => {
expect(applyDiscount(100, { type: 'percent', value: 20 })).toBe(80);
});
it('applies fixed discount', () => {
expect(applyDiscount(100, { type: 'fixed', value: 15 })).toBe(85);
});
it('does not go below zero', () => {
expect(applyDiscount(10, { type: 'fixed', value: 50 })).toBe(0);
});
});
describe('calculateTax', () => {
it('calculates tax by state', () => {
expect(calculateTax(100, 'CA')).toBe(7.25);
expect(calculateTax(100, 'TX')).toBe(6.25);
expect(calculateTax(100, 'OR')).toBe(0); // Oregon has no sales tax
});
});Inventory Validation
Test stock reservation logic before touching a database:
// inventory.test.js
import { canFulfill, reserveStock, releaseStock } from '../inventory';
describe('canFulfill', () => {
it('returns true when stock is available', () => {
const stock = { sku: 'SHOE-42', available: 10, reserved: 2 };
expect(canFulfill(stock, 5)).toBe(true);
});
it('returns false when requested quantity exceeds available minus reserved', () => {
const stock = { sku: 'SHOE-42', available: 10, reserved: 8 };
expect(canFulfill(stock, 5)).toBe(false);
});
it('prevents overselling at exact boundary', () => {
const stock = { sku: 'SHOE-42', available: 5, reserved: 3 };
expect(canFulfill(stock, 2)).toBe(true);
expect(canFulfill(stock, 3)).toBe(false);
});
});Order State Machine
Order status transitions have strict rules — unit test the state machine directly:
// order-state.test.js
import { transition } from '../order-state-machine';
describe('order state transitions', () => {
it('allows pending → paid', () => {
expect(transition('pending', 'payment_received')).toBe('paid');
});
it('allows paid → shipped', () => {
expect(transition('paid', 'fulfillment_shipped')).toBe('shipped');
});
it('prevents shipped → pending', () => {
expect(() => transition('shipped', 'payment_reversed')).toThrow('Invalid transition');
});
it('allows paid → refunded', () => {
expect(transition('paid', 'refund_issued')).toBe('refunded');
});
});Integration Testing
Integration tests verify your application works correctly with real external systems — using test credentials, test databases, and sandbox payment environments.
Testing Payment Gateway Integration
// stripe-integration.test.js
import Stripe from 'stripe';
import { createPaymentIntent, capturePayment } from '../payments';
const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY);
describe('Stripe payment integration', () => {
it('creates a payment intent for valid amount', async () => {
const intent = await createPaymentIntent({
amount: 4999,
currency: 'usd',
metadata: { orderId: 'test-order-1' },
});
expect(intent.status).toBe('requires_payment_method');
expect(intent.amount).toBe(4999);
});
it('captures a confirmed payment', async () => {
// Create and confirm with test card
const intent = await stripe.paymentIntents.create({
amount: 2000,
currency: 'usd',
payment_method: 'pm_card_visa',
confirm: true,
return_url: 'https://example.com/return',
});
const result = await capturePayment(intent.id);
expect(result.status).toBe('succeeded');
});
});Testing Inventory Sync with Database
// inventory-db.test.js
import { db } from '../db';
import { reserveStock, releaseStock } from '../inventory-service';
beforeEach(async () => {
await db.query('BEGIN');
await db.query(`INSERT INTO inventory (sku, available, reserved) VALUES ('TEST-SKU', 10, 0)`);
});
afterEach(async () => {
await db.query('ROLLBACK');
});
test('reserves stock and decrements available', async () => {
await reserveStock('TEST-SKU', 3);
const row = await db.query('SELECT * FROM inventory WHERE sku = $1', ['TEST-SKU']);
expect(row.rows[0].reserved).toBe(3);
expect(row.rows[0].available).toBe(7);
});
test('prevents concurrent overselling', async () => {
const attempts = Array.from({ length: 12 }, () => reserveStock('TEST-SKU', 1));
const results = await Promise.allSettled(attempts);
const successes = results.filter(r => r.status === 'fulfilled');
expect(successes.length).toBe(10); // Only 10 in stock
});Testing Webhook Handlers
Payment webhooks must be tested with correct signature verification:
// webhook.test.js
import Stripe from 'stripe';
import request from 'supertest';
import app from '../app';
const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
function buildWebhookPayload(type, data) {
const payload = JSON.stringify({ type, data: { object: data } });
const timestamp = Math.floor(Date.now() / 1000);
const signature = stripe.webhooks.generateTestHeaderString({
payload,
secret: webhookSecret,
timestamp,
});
return { payload, signature };
}
test('handles payment_intent.succeeded webhook', async () => {
const { payload, signature } = buildWebhookPayload('payment_intent.succeeded', {
id: 'pi_test',
metadata: { orderId: 'order-123' },
amount: 5000,
});
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', signature)
.send(payload);
expect(res.status).toBe(200);
// Verify order was updated in DB
const order = await db.findOrder('order-123');
expect(order.status).toBe('paid');
});End-to-End Testing Critical Flows
E2E tests should cover the paths that, if broken, immediately cost money or cause refunds.
Checkout Flow with Playwright
// checkout.e2e.js
import { test, expect } from '@playwright/test';
test('complete checkout flow with credit card', async ({ page }) => {
// Add product to cart
await page.goto('/products/test-product');
await page.click('[data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// Go to checkout
await page.goto('/checkout');
await expect(page.locator('h1')).toContainText('Checkout');
// Fill shipping address
await page.fill('[name="firstName"]', 'Test');
await page.fill('[name="lastName"]', 'User');
await page.fill('[name="address"]', '123 Test St');
await page.fill('[name="city"]', 'San Francisco');
await page.selectOption('[name="state"]', 'CA');
await page.fill('[name="zip"]', '94105');
// Select shipping method
await page.click('[data-testid="shipping-standard"]');
// Verify order summary
await expect(page.locator('[data-testid="subtotal"]')).toBeVisible();
await expect(page.locator('[data-testid="tax"]')).toBeVisible();
await expect(page.locator('[data-testid="total"]')).toBeVisible();
// Enter payment (Stripe test card)
const stripeFrame = page.frameLocator('[data-testid="stripe-card-element"] iframe');
await stripeFrame.locator('[name="cardnumber"]').fill('4242424242424242');
await stripeFrame.locator('[name="exp-date"]').fill('12/28');
await stripeFrame.locator('[name="cvc"]').fill('123');
// Place order
await page.click('[data-testid="place-order"]');
// Verify confirmation
await expect(page).toHaveURL(/\/order-confirmation/);
await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
await expect(page.locator('[data-testid="confirmation-email-notice"]')).toBeVisible();
});
test('shows error for declined card', async ({ page }) => {
await page.goto('/checkout');
// ... fill form ...
const stripeFrame = page.frameLocator('[data-testid="stripe-card-element"] iframe');
await stripeFrame.locator('[name="cardnumber"]').fill('4000000000000002'); // always-decline test card
await stripeFrame.locator('[name="exp-date"]').fill('12/28');
await stripeFrame.locator('[name="cvc"]').fill('123');
await page.click('[data-testid="place-order"]');
await expect(page.locator('[data-testid="payment-error"]')).toContainText('declined');
await expect(page).toHaveURL(/\/checkout/); // stays on checkout
});Cart Persistence Test
test('cart persists across page reloads', async ({ page }) => {
await page.goto('/products/blue-sneakers');
await page.click('[data-testid="add-to-cart"]');
await page.reload();
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
await page.goto('/cart');
await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1);
await expect(page.locator('[data-testid="cart-item-name"]')).toContainText('Blue Sneakers');
});Testing Inventory Edge Cases
Some of the most expensive bugs in e-commerce involve inventory. Test these explicitly:
describe('inventory edge cases', () => {
test('prevents adding out-of-stock item to cart', async ({ page }) => {
// Set up: mark item as out of stock
await db.query(`UPDATE inventory SET available = 0 WHERE sku = 'TEST-SKU'`);
await page.goto('/products/test-product');
await expect(page.locator('[data-testid="add-to-cart"]')).toBeDisabled();
await expect(page.locator('[data-testid="out-of-stock-badge"]')).toBeVisible();
});
test('handles item going out of stock during checkout', async ({ page }) => {
// User has item in cart, then another user buys last item
await addToCart(page, 'TEST-SKU');
await depleteStock('TEST-SKU');
await page.goto('/checkout');
await page.click('[data-testid="place-order"]');
await expect(page.locator('[data-testid="stock-error"]')).toContainText('no longer available');
});
});CI/CD Pipeline for E-commerce Tests
Structure your test pipeline to fail fast on the cheapest tests first:
# .github/workflows/test.yml
name: E-commerce Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --testPathPattern="unit"
integration:
needs: unit
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
env:
STRIPE_TEST_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --testPathPattern="integration"
e2e:
needs: integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
env:
BASE_URL: http://localhost:3000
STRIPE_TEST_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}Key Metrics to Track
After implementing your test strategy, monitor:
| Metric | Target |
|---|---|
| Unit test coverage (business logic) | > 90% |
| Checkout E2E pass rate | 100% |
| Payment integration test coverage | All happy paths + top 5 error cases |
| Inventory concurrent test coverage | Race conditions tested |
| Test suite duration | < 5 min (unit), < 15 min (integration), < 30 min (E2E) |
Summary
A good e-commerce testing strategy starts with heavy unit test coverage of cart math, pricing, and order state machines. Integration tests verify your payment gateway and inventory database work correctly under realistic conditions. End-to-end tests protect only the most critical user journeys — checkout completion and order confirmation.
Don't try to E2E test everything. Test the flows where a failure means a customer can't give you money. For everything else, unit and integration tests are faster, more reliable, and easier to maintain.