Page Object Model for E-Commerce
Your test suite will grow to hundreds of tests. Without structure, you'll have duplicated locators everywhere, and a single UI change will break dozens of tests. The Page Object Model (POM) solves this.
The Problem: Test Maintenance
Without POM, tests look like this:
// test-1.spec.ts
test('can add to cart', async ({ page }) => {
await page.goto('/products/laptop');
await page.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.locator('.cart-count')).toHaveText('1');
});
// test-2.spec.ts
test('can checkout', async ({ page }) => {
await page.goto('/products/laptop');
await page.getByRole('button', { name: 'Add to Cart' }).click(); // Duplicate
await page.locator('.cart-count').click(); // Same locator
// ...more duplicates
});
When the cart count class changes from .cart-count to .CartBadge__count, you fix it in 50 places.
Three-Layer Architecture
The three layers separate concerns:
- Test Layer: What we're testing (test cases, assertions, test data)
- Flow Layer: How business processes work (multi-page workflows)
- Page Object Layer: Where elements are and how to interact (single-page actions)
Project Structure
tests/
├── pages/
│ ├── BasePage.ts
│ ├── HomePage.ts
│ ├── ProductPage.ts
│ ├── CartPage.ts
│ ├── CheckoutPage.ts
│ └── OrderConfirmationPage.ts
├── components/
│ ├── Header.ts
│ ├── ProductCard.ts
│ ├── CartItem.ts
│ └── Footer.ts
├── flows/
│ ├── CheckoutFlow.ts
│ └── SearchFlow.ts
├── fixtures/
│ └── test-fixtures.ts
└── specs/
├── cart.spec.ts
├── checkout.spec.ts
└── search.spec.ts
Building Page Objects
Base Page
// pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export class BasePage {
readonly page: Page;
readonly header: Locator;
readonly footer: Locator;
readonly loadingSpinner: Locator;
constructor(page: Page) {
this.page = page;
this.header = page.getByRole('banner');
this.footer = page.getByRole('contentinfo');
this.loadingSpinner = page.getByTestId('loading-spinner');
}
async waitForPageLoad(): Promise<void> {
await this.loadingSpinner.waitFor({ state: 'hidden' });
}
async getCartCount(): Promise<string> {
return await this.header.getByTestId('cart-count').textContent() || '0';
}
async goToCart(): Promise<void> {
await this.header.getByRole('link', { name: 'Cart' }).click();
}
}
Product Page
// pages/ProductPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class ProductPage extends BasePage {
readonly productTitle: Locator;
readonly productPrice: Locator;
readonly productDescription: Locator;
readonly productImage: Locator;
readonly quantityInput: Locator;
readonly addToCartButton: Locator;
readonly sizeSelector: Locator;
readonly colorSelector: Locator;
readonly stockStatus: Locator;
readonly addToWishlistButton: Locator;
constructor(page: Page) {
super(page);
this.productTitle = page.getByRole('heading', { level: 1 });
this.productPrice = page.getByTestId('product-price');
this.productDescription = page.getByTestId('product-description');
this.productImage = page.getByRole('img', { name: /product/i });
this.quantityInput = page.getByRole('spinbutton', { name: 'Quantity' });
this.addToCartButton = page.getByRole('button', { name: 'Add to Cart' });
this.sizeSelector = page.getByRole('combobox', { name: 'Size' });
this.colorSelector = page.getByRole('combobox', { name: 'Color' });
this.stockStatus = page.getByTestId('stock-status');
this.addToWishlistButton = page.getByRole('button', { name: 'Add to Wishlist' });
}
async goto(productSlug: string): Promise<void> {
await this.page.goto(`/products/${productSlug}`);
await this.waitForPageLoad();
}
async getPrice(): Promise<string> {
const price = await this.productPrice.textContent();
return price || '';
}
async selectSize(size: string): Promise<void> {
await this.sizeSelector.selectOption(size);
}
async selectColor(color: string): Promise<void> {
await this.colorSelector.selectOption(color);
}
async setQuantity(quantity: number): Promise<void> {
await this.quantityInput.clear();
await this.quantityInput.fill(quantity.toString());
}
async addToCart(options?: { quantity?: number; size?: string; color?: string }): Promise<void> {
if (options?.size) {
await this.selectSize(options.size);
}
if (options?.color) {
await this.selectColor(options.color);
}
if (options?.quantity) {
await this.setQuantity(options.quantity);
}
await this.addToCartButton.click();
}
async isInStock(): Promise<boolean> {
const status = await this.stockStatus.textContent();
return status?.toLowerCase().includes('in stock') || false;
}
async expectProductLoaded(productName: string): Promise<void> {
await expect(this.productTitle).toContainText(productName);
await expect(this.productImage).toBeVisible();
await expect(this.productPrice).toBeVisible();
}
}
Cart Page
// pages/CartPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
interface CartItemData {
name: string;
price: string;
quantity: string;
subtotal: string;
}
export class CartPage extends BasePage {
readonly cartHeading: Locator;
readonly cartItems: Locator;
readonly cartSubtotal: Locator;
readonly cartTax: Locator;
readonly cartTotal: Locator;
readonly promoCodeInput: Locator;
readonly applyPromoButton: Locator;
readonly promoMessage: Locator;
readonly checkoutButton: Locator;
readonly continueShoppingButton: Locator;
readonly emptyCartMessage: Locator;
constructor(page: Page) {
super(page);
this.cartHeading = page.getByRole('heading', { name: 'Your Cart' });
this.cartItems = page.getByTestId('cart-item');
this.cartSubtotal = page.getByTestId('cart-subtotal');
this.cartTax = page.getByTestId('cart-tax');
this.cartTotal = page.getByTestId('cart-total');
this.promoCodeInput = page.getByPlaceholder('Promo code');
this.applyPromoButton = page.getByRole('button', { name: 'Apply' });
this.promoMessage = page.getByTestId('promo-message');
this.checkoutButton = page.getByRole('button', { name: 'Proceed to Checkout' });
this.continueShoppingButton = page.getByRole('link', { name: 'Continue Shopping' });
this.emptyCartMessage = page.getByText('Your cart is empty');
}
async goto(): Promise<void> {
await this.page.goto('/cart');
await this.waitForPageLoad();
}
getCartItem(productName: string): Locator {
return this.cartItems.filter({ hasText: productName });
}
async getItemQuantityInput(productName: string): Promise<Locator> {
return this.getCartItem(productName).getByRole('spinbutton');
}
async updateItemQuantity(productName: string, quantity: number): Promise<void> {
const input = await this.getItemQuantityInput(productName);
await input.clear();
await input.fill(quantity.toString());
await input.press('Enter');
await this.waitForPageLoad();
}
async removeItem(productName: string): Promise<void> {
await this.getCartItem(productName)
.getByRole('button', { name: 'Remove' })
.click();
await this.waitForPageLoad();
}
async getItemCount(): Promise<number> {
return await this.cartItems.count();
}
async getTotal(): Promise<string> {
return await this.cartTotal.textContent() || '';
}
async applyPromoCode(code: string): Promise<void> {
await this.promoCodeInput.fill(code);
await this.applyPromoButton.click();
await this.waitForPageLoad();
}
async proceedToCheckout(): Promise<void> {
await this.checkoutButton.click();
}
async expectCartHasItems(count: number): Promise<void> {
await expect(this.cartItems).toHaveCount(count);
}
async expectTotal(amount: string): Promise<void> {
await expect(this.cartTotal).toHaveText(amount);
}
async expectEmpty(): Promise<void> {
await expect(this.emptyCartMessage).toBeVisible();
}
}
Checkout Page
// pages/CheckoutPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
interface ShippingAddress {
firstName: string;
lastName: string;
email: string;
address: string;
city: string;
state: string;
zipCode: string;
phone?: string;
}
interface PaymentInfo {
cardNumber: string;
expiration: string;
cvv: string;
nameOnCard: string;
}
export class CheckoutPage extends BasePage {
// Shipping section
readonly shippingSection: Locator;
readonly firstNameInput: Locator;
readonly lastNameInput: Locator;
readonly emailInput: Locator;
readonly addressInput: Locator;
readonly cityInput: Locator;
readonly stateSelect: Locator;
readonly zipCodeInput: Locator;
readonly phoneInput: Locator;
// Shipping methods
readonly standardShipping: Locator;
readonly expressShipping: Locator;
readonly overnightShipping: Locator;
// Payment section
readonly paymentSection: Locator;
readonly cardNumberInput: Locator;
readonly expirationInput: Locator;
readonly cvvInput: Locator;
readonly nameOnCardInput: Locator;
// Order summary
readonly orderSummary: Locator;
readonly orderSubtotal: Locator;
readonly orderShipping: Locator;
readonly orderTax: Locator;
readonly orderTotal: Locator;
// Actions
readonly placeOrderButton: Locator;
readonly backToCartButton: Locator;
// Errors
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
// Shipping
this.shippingSection = page.getByRole('group', { name: /shipping/i });
this.firstNameInput = page.getByLabel('First Name');
this.lastNameInput = page.getByLabel('Last Name');
this.emailInput = page.getByLabel('Email');
this.addressInput = page.getByLabel('Street Address');
this.cityInput = page.getByLabel('City');
this.stateSelect = page.getByLabel('State');
this.zipCodeInput = page.getByLabel(/zip|postal/i);
this.phoneInput = page.getByLabel('Phone');
// Shipping methods
this.standardShipping = page.getByLabel(/standard.*shipping/i);
this.expressShipping = page.getByLabel(/express.*shipping/i);
this.overnightShipping = page.getByLabel(/overnight.*shipping/i);
// Payment
this.paymentSection = page.getByRole('group', { name: /payment/i });
this.cardNumberInput = page.getByLabel(/card number/i);
this.expirationInput = page.getByLabel(/expir/i);
this.cvvInput = page.getByLabel(/cvv|cvc|security code/i);
this.nameOnCardInput = page.getByLabel(/name on card/i);
// Summary
this.orderSummary = page.getByTestId('order-summary');
this.orderSubtotal = page.getByTestId('order-subtotal');
this.orderShipping = page.getByTestId('order-shipping');
this.orderTax = page.getByTestId('order-tax');
this.orderTotal = page.getByTestId('order-total');
// Actions
this.placeOrderButton = page.getByRole('button', { name: /place order|complete purchase/i });
this.backToCartButton = page.getByRole('link', { name: /back to cart/i });
// Errors
this.errorMessage = page.getByRole('alert');
}
async goto(): Promise<void> {
await this.page.goto('/checkout');
await this.waitForPageLoad();
}
async fillShippingAddress(address: ShippingAddress): Promise<void> {
await this.firstNameInput.fill(address.firstName);
await this.lastNameInput.fill(address.lastName);
await this.emailInput.fill(address.email);
await this.addressInput.fill(address.address);
await this.cityInput.fill(address.city);
await this.stateSelect.selectOption(address.state);
await this.zipCodeInput.fill(address.zipCode);
if (address.phone) {
await this.phoneInput.fill(address.phone);
}
}
async selectShippingMethod(method: 'standard' | 'express' | 'overnight'): Promise<void> {
const shippingOptions = {
standard: this.standardShipping,
express: this.expressShipping,
overnight: this.overnightShipping,
};
await shippingOptions[method].check();
}
async fillPaymentInfo(payment: PaymentInfo): Promise<void> {
await this.cardNumberInput.fill(payment.cardNumber);
await this.expirationInput.fill(payment.expiration);
await this.cvvInput.fill(payment.cvv);
await this.nameOnCardInput.fill(payment.nameOnCard);
}
async placeOrder(): Promise<void> {
await this.placeOrderButton.click();
}
async expectTotal(amount: string): Promise<void> {
await expect(this.orderTotal).toHaveText(amount);
}
async expectError(message: string | RegExp): Promise<void> {
await expect(this.errorMessage).toContainText(message);
}
}
Component Objects
For shared UI elements that appear on multiple pages:
// components/Header.ts
import { Page, Locator } from '@playwright/test';
export class Header {
readonly container: Locator;
readonly logo: Locator;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly cartLink: Locator;
readonly cartCount: Locator;
readonly accountMenu: Locator;
readonly loginLink: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
this.container = page.getByRole('banner');
this.logo = this.container.getByRole('link', { name: /home|logo/i });
this.searchInput = this.container.getByRole('searchbox');
this.searchButton = this.container.getByRole('button', { name: 'Search' });
this.cartLink = this.container.getByRole('link', { name: /cart/i });
this.cartCount = this.container.getByTestId('cart-count');
this.accountMenu = this.container.getByRole('button', { name: /account/i });
this.loginLink = this.container.getByRole('link', { name: 'Login' });
this.logoutButton = this.container.getByRole('button', { name: 'Logout' });
}
async search(query: string): Promise<void> {
await this.searchInput.fill(query);
await this.searchButton.click();
}
async goToCart(): Promise<void> {
await this.cartLink.click();
}
async getCartItemCount(): Promise<number> {
const text = await this.cartCount.textContent();
return parseInt(text || '0', 10);
}
}
// components/ProductCard.ts
import { Locator } from '@playwright/test';
export class ProductCard {
readonly container: Locator;
readonly name: Locator;
readonly price: Locator;
readonly image: Locator;
readonly addToCartButton: Locator;
readonly viewDetailsLink: Locator;
constructor(container: Locator) {
this.container = container;
this.name = container.getByRole('heading');
this.price = container.getByTestId('price');
this.image = container.getByRole('img');
this.addToCartButton = container.getByRole('button', { name: 'Add to Cart' });
this.viewDetailsLink = container.getByRole('link', { name: 'View Details' });
}
async getName(): Promise<string> {
return await this.name.textContent() || '';
}
async getPrice(): Promise<string> {
return await this.price.textContent() || '';
}
async addToCart(): Promise<void> {
await this.addToCartButton.click();
}
async viewDetails(): Promise<void> {
await this.viewDetailsLink.click();
}
}
Flow Layer
For multi-page business processes:
// flows/CheckoutFlow.ts
import { Page } from '@playwright/test';
import { CartPage } from '../pages/CartPage';
import { CheckoutPage } from '../pages/CheckoutPage';
interface CheckoutData {
shipping: {
firstName: string;
lastName: string;
email: string;
address: string;
city: string;
state: string;
zipCode: string;
};
shippingMethod: 'standard' | 'express' | 'overnight';
payment: {
cardNumber: string;
expiration: string;
cvv: string;
nameOnCard: string;
};
}
export class CheckoutFlow {
private cartPage: CartPage;
private checkoutPage: CheckoutPage;
constructor(page: Page) {
this.cartPage = new CartPage(page);
this.checkoutPage = new CheckoutPage(page);
}
async completeCheckout(data: CheckoutData): Promise<void> {
// From cart to checkout
await this.cartPage.proceedToCheckout();
// Fill shipping
await this.checkoutPage.fillShippingAddress(data.shipping);
await this.checkoutPage.selectShippingMethod(data.shippingMethod);
// Fill payment
await this.checkoutPage.fillPaymentInfo(data.payment);
// Place order
await this.checkoutPage.placeOrder();
}
async applyPromoAndCheckout(promoCode: string, data: CheckoutData): Promise<void> {
await this.cartPage.applyPromoCode(promoCode);
await this.completeCheckout(data);
}
}
Using Page Objects in Tests
// specs/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { ProductPage } from '../pages/ProductPage';
import { CartPage } from '../pages/CartPage';
import { CheckoutPage } from '../pages/CheckoutPage';
import { CheckoutFlow } from '../flows/CheckoutFlow';
const testShipping = {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
address: '123 Main St',
city: 'San Francisco',
state: 'California',
zipCode: '94102',
};
const testPayment = {
cardNumber: '4111111111111111',
expiration: '12/25',
cvv: '123',
nameOnCard: 'John Doe',
};
test.describe('Checkout Flow', () => {
test('complete purchase with single item', async ({ page }) => {
const productPage = new ProductPage(page);
const cartPage = new CartPage(page);
const checkoutPage = new CheckoutPage(page);
// Add product to cart
await productPage.goto('wireless-headphones');
await productPage.addToCart({ quantity: 1, color: 'Black' });
// Go to cart and verify
await cartPage.goto();
await cartPage.expectCartHasItems(1);
// Checkout
await cartPage.proceedToCheckout();
await checkoutPage.fillShippingAddress(testShipping);
await checkoutPage.selectShippingMethod('standard');
await checkoutPage.fillPaymentInfo(testPayment);
await checkoutPage.placeOrder();
// Verify success
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
test('complete purchase using flow helper', async ({ page }) => {
const productPage = new ProductPage(page);
const cartPage = new CartPage(page);
const checkoutFlow = new CheckoutFlow(page);
// Setup: Add product
await productPage.goto('wireless-headphones');
await productPage.addToCart();
await cartPage.goto();
// Execute entire checkout via flow
await checkoutFlow.completeCheckout({
shipping: testShipping,
shippingMethod: 'express',
payment: testPayment,
});
// Verify
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
test('apply promo code during checkout', async ({ page }) => {
const productPage = new ProductPage(page);
const cartPage = new CartPage(page);
await productPage.goto('laptop');
await productPage.addToCart();
await cartPage.goto();
await cartPage.applyPromoCode('SAVE20');
await expect(cartPage.promoMessage).toContainText('20% off');
});
});
Key Takeaways
- Single source of truth: Locators defined once in page objects
- Three layers: Pages (where), Flows (how), Tests (what)
- DRY principle: No duplicate locators or setup code
- Easy maintenance: UI change = one file update
- Readability: Tests read like business requirements
- Components: Reusable elements shared across pages
Next Lesson
Your tests are organized and maintainable. But they're still slow because they go through the UI for everything. In the next lesson, we'll learn API testing and hybrid workflows to dramatically speed up test execution.