E2E Order Placement Flow with POM + Smart Locators

Master end-to-end testing with Page Object Model and intelligent element selection

🕒 Teaching Flow

1. Story / Intro (5 min)

👉 "Yesterday we stored session → now we can skip login each time. Today we'll build an end-to-end Order Placement flow using Page Objects. Each page does its job, like actors in a play, and our spec file becomes the director calling them."
POM Analogy:
Think of a theater play where each actor (Page Object) has a specific role and knows their lines (methods). The director (test spec) coordinates the actors to tell a complete story (E2E flow).

E2E Flow Overview

🔄 Complete Order Placement Journey

Login (using stored session)
Search for product
Add product to cart
Proceed to checkout
Place order
Capture order ID
Verify order confirmation

🎭 Page Object Model Benefits

  • Separation of Concerns: Each page handles its own elements and actions
  • Reusability: Page objects can be used across multiple tests
  • Maintainability: Changes to UI only require updating one page object
  • Readability: Tests read like user stories
  • Scalability: Easy to add new pages and functionality

POM Page Classes (30 min)

LoginPage.js

// pages/LoginPage.js
export class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailField = page.getByLabel("Email address");
    this.passwordField = page.getByLabel("Password");
    this.loginButton = page.getByRole("button", { name: "Sign in" });
    this.errorMessage = page.getByText("Authentication failed");
  }

  async goto() {
    await this.page.goto('https://learn-playwright.great-site.net/');
  }

  async login(email, password) {
    await this.emailField.fill(email);
    await this.passwordField.fill(password);
    await this.loginButton.click();
  }

  async isLoggedIn() {
    return await this.page.getByText("Sign out").isVisible();
  }

  async getErrorMessage() {
    return await this.errorMessage.textContent();
  }
}

HomePage.js

// pages/HomePage.js
export class HomePage {
  constructor(page) {
    this.page = page;
    this.searchBox = page.getByPlaceholder("Search our catalog");
    this.clothesCategory = page.getByRole("link", { name: "Clothes" });
    this.accessoriesCategory = page.getByRole("link", { name: "Accessories" });
    this.artCategory = page.getByRole("link", { name: "Art" });
  }

  async searchProduct(productName) {
    await this.searchBox.fill(productName);
    await this.searchBox.press('Enter');
  }

  async clickCategory(categoryName) {
    const categoryMap = {
      'clothes': this.clothesCategory,
      'accessories': this.accessoriesCategory,
      'art': this.artCategory
    };
    
    if (categoryMap[categoryName.toLowerCase()]) {
      await categoryMap[categoryName.toLowerCase()].click();
    } else {
      throw new Error(`Category "${categoryName}" not found`);
    }
  }

  async getProductNames() {
    const productElements = await this.page.locator('.product-miniature h3').all();
    return await Promise.all(productElements.map(el => el.textContent()));
  }

  async clickProduct(productName) {
    await this.page.locator('.product-title a').filter({ hasText: productName }).click();
  }
}

ProductPage.js

// pages/ProductPage.js
export class ProductPage {
  constructor(page) {
    this.page = page;
    this.addToCartButton = page.getByRole("button", { name: "Add to cart" });
    this.productTitle = page.locator('h1');
    this.productPrice = page.locator('.current-price');
    this.quantityInput = page.getByLabel("Quantity");
    this.sizeSelect = page.getByLabel("Size");
    this.colorSelect = page.getByLabel("Color");
  }

  async addToCart() {
    await this.addToCartButton.click();
  }

  async addSpecificProductToCart(productName, quantity = 1, size = null, color = null) {
    // Navigate to specific product page
    await this.page
      .locator(".product-miniature")
      .filter({ hasText: productName })
      .click();

    // Set quantity if specified
    if (quantity > 1) {
      await this.quantityInput.fill(quantity.toString());
    }

    // Select size if specified
    if (size) {
      await this.sizeSelect.selectOption(size);
    }

    // Select color if specified
    if (color) {
      await this.colorSelect.selectOption(color);
    }

    // Add to cart
    await this.addToCartButton.click();
  }

  async getProductInfo() {
    return {
      title: await this.productTitle.textContent(),
      price: await this.productPrice.textContent()
    };
  }
}

CartPage.js

// pages/CartPage.js
export class CartPage {
  constructor(page) {
    this.page = page;
    this.checkoutButton = page.getByRole("link", { name: "Proceed to checkout" });
    this.cartItems = page.locator('.cart-item');
    this.totalPrice = page.locator('.cart-total .value');
    this.removeItemButton = page.getByRole("button", { name: "Remove" });
    this.quantityInput = page.getByLabel("Quantity");
  }

  async proceedToCheckout() {
    await this.checkoutButton.click();
  }

  async getCartItems() {
    const items = await this.cartItems.all();
    return await Promise.all(items.map(async (item) => ({
      name: await item.locator('.product-name').textContent(),
      price: await item.locator('.price').textContent(),
      quantity: await item.locator('.quantity input').inputValue()
    })));
  }

  async getTotalPrice() {
    return await this.totalPrice.textContent();
  }

  async removeItem(productName) {
    await this.page
      .locator('.cart-item')
      .filter({ hasText: productName })
      .getByRole("button", { name: "Remove" })
      .click();
  }

  async updateQuantity(productName, newQuantity) {
    await this.page
      .locator('.cart-item')
      .filter({ hasText: productName })
      .getByLabel("Quantity")
      .fill(newQuantity.toString());
  }
}

CheckoutPage.js

// pages/CheckoutPage.js
export class CheckoutPage {
  constructor(page) {
    this.page = page;
    this.termsCheckbox = page.getByLabel("I agree to the terms of service");
    this.placeOrderButton = page.getByRole("button", { name: "Place order" });
    this.orderConfirmation = page.getByText("Order confirmation");
    this.orderReference = page.locator(".order-reference");
    this.paymentMethod = page.getByLabel("Payment method");
    this.shippingAddress = page.locator('.address');
    this.billingAddress = page.locator('.billing-address');
  }

  async placeOrder() {
    await this.termsCheckbox.check();
    await this.placeOrderButton.click();
  }

  async placeOrderWithoutTerms() {
    // Intentionally skip terms checkbox for negative testing
    await this.placeOrderButton.click();
  }

  async getOrderId() {
    await this.page.waitForSelector('.order-reference');
    return await this.orderReference.textContent();
  }

  async getOrderDetails() {
    return {
      orderId: await this.getOrderId(),
      confirmation: await this.orderConfirmation.isVisible(),
      paymentMethod: await this.paymentMethod.textContent(),
      shippingAddress: await this.shippingAddress.textContent(),
      billingAddress: await this.billingAddress.textContent()
    };
  }

  async isOrderConfirmed() {
    return await this.orderConfirmation.isVisible();
  }

  async getErrorMessage() {
    const errorElement = this.page.locator('.alert-danger');
    if (await errorElement.isVisible()) {
      return await errorElement.textContent();
    }
    return null;
  }
}

OrderHistoryPage.js

// pages/OrderHistoryPage.js
export class OrderHistoryPage {
  constructor(page) {
    this.page = page;
    this.orderHistoryLink = page.getByRole("link", { name: "Order history and details" });
    this.orderRows = page.locator('.order');
    this.orderReference = page.locator('.order-reference');
    this.orderDate = page.locator('.order-date');
    this.orderTotal = page.locator('.order-total');
  }

  async goto() {
    await this.page.goto('https://learn-playwright.great-site.net/my-account');
    await this.orderHistoryLink.click();
  }

  async findOrder(orderId) {
    const orderElement = this.page
      .locator('.order')
      .filter({ hasText: orderId });
    
    if (await orderElement.isVisible()) {
      return {
        found: true,
        orderId: await orderElement.locator('.order-reference').textContent(),
        date: await orderElement.locator('.order-date').textContent(),
        total: await orderElement.locator('.order-total').textContent(),
        status: await orderElement.locator('.order-status').textContent()
      };
    }
    
    return { found: false };
  }

  async getAllOrders() {
    const orders = await this.orderRows.all();
    return await Promise.all(orders.map(async (order) => ({
      orderId: await order.locator('.order-reference').textContent(),
      date: await order.locator('.order-date').textContent(),
      total: await order.locator('.order-total').textContent(),
      status: await order.locator('.order-status').textContent()
    })));
  }

  async getOrderCount() {
    return await this.orderRows.count();
  }
}

E2E Spec File (30 min)

// tests/e2e-order.spec.js
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage.js';
import { HomePage } from '../pages/HomePage.js';
import { ProductPage } from '../pages/ProductPage.js';
import { CartPage } from '../pages/CartPage.js';
import { CheckoutPage } from '../pages/CheckoutPage.js';
import { OrderHistoryPage } from '../pages/OrderHistoryPage.js';
import fs from 'fs';

test.describe('E2E Order Placement Flow', () => {
  test('Complete Order Placement Journey', async ({ page }) => {
    // Initialize page objects
    const loginPage = new LoginPage(page);
    const homePage = new HomePage(page);
    const productPage = new ProductPage(page);
    const cartPage = new CartPage(page);
    const checkoutPage = new CheckoutPage(page);
    const orderHistoryPage = new OrderHistoryPage(page);

    // Step 1: Go to site (session will be automatically applied)
    await loginPage.goto();
    await expect(loginPage.page).toHaveURL(/.*learn-playwright\.great-site\.net/);

    // Step 2: Search for product
    await homePage.searchProduct("shirt");
    await expect(page).toHaveURL(/.*search/);
    await productPage.clickProduct("Hummingbird printed t-shirt");

    // Step 3: Add product to cart
    await productPage.addToCart();
    
    // Wait for cart to update
    await page.waitForSelector('.cart-products-count');

    // Step 4: Proceed to checkout
    await cartPage.proceedToCheckout();
    await expect(page).toHaveURL(/.*checkout/);

    // Step 5: Place order
    await checkoutPage.placeOrder();
    
    // Step 6: Capture Order ID
    const orderId = await checkoutPage.getOrderId();
    console.log("✅ Order placed successfully! Order ID:", orderId);

    // Step 7: Verify order confirmation
    await expect(checkoutPage.orderConfirmation).toBeVisible();
    await expect(checkoutPage.orderReference).toContainText(orderId);

    // Step 8: Save order ID to file for future reference
    const orderData = {
      orderId: orderId,
      timestamp: new Date().toISOString(),
      product: "Printed Dress",
      status: "confirmed"
    };

    // Read existing orders or create new array
    let orders = [];
    try {
      const existingData = fs.readFileSync('orders.json', 'utf8');
      orders = JSON.parse(existingData);
    } catch (error) {
      // File doesn't exist, start with empty array
    }

    orders.push(orderData);
    fs.writeFileSync('orders.json', JSON.stringify(orders, null, 2));

    // Step 9: Verify order in order history
    await orderHistoryPage.goto();
    const orderInHistory = await orderHistoryPage.findOrder(orderId);
    
    expect(orderInHistory.found).toBe(true);
    expect(orderInHistory.orderId).toContain(orderId);
  });

  test('Order Placement with Multiple Products', async ({ page }) => {
    const homePage = new HomePage(page);
    const productPage = new ProductPage(page);
    const cartPage = new CartPage(page);
    const checkoutPage = new CheckoutPage(page);

    await homePage.goto();

    // Add multiple products
    await productPage.addToCart("Printed Dress");
    await productPage.addToCart("Printed Summer Dress");
    await productPage.addToCart("Printed Chiffon Dress");

    // Verify cart has multiple items
    const cartItems = await cartPage.getCartItems();
    expect(cartItems.length).toBeGreaterThanOrEqual(3);

    // Proceed to checkout
    await cartPage.proceedToCheckout();
    await checkoutPage.placeOrder();

    const orderId = await checkoutPage.getOrderId();
    expect(orderId).toBeTruthy();
  });

  test('Negative Test - Checkout without Terms', async ({ page }) => {
    const homePage = new HomePage(page);
    const productPage = new ProductPage(page);
    const cartPage = new CartPage(page);
    const checkoutPage = new CheckoutPage(page);

    await homePage.goto();
    await productPage.addToCart("Printed Dress");
    await cartPage.proceedToCheckout();

    // Try to place order without accepting terms
    await checkoutPage.placeOrderWithoutTerms();

    // Should show error message
    const errorMessage = await checkoutPage.getErrorMessage();
    expect(errorMessage).toContain("terms");
  });
});

New Concept: getByRole (15 min)

👉 Why getByRole?
  • It finds elements based on ARIA roles (button, link, checkbox).
  • Accessible & resilient → less likely to break than CSS.
  • Semantic meaning over visual appearance.
  • Better for accessibility testing.

getByRole Examples:

// Instead of brittle CSS selectors
await page.click('button#SubmitLogin');
await page.click('.btn-primary');

// Use semantic role-based selectors
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByRole("link", { name: "Proceed to checkout" }).click();
await page.getByRole("checkbox", { name: "I agree to terms" }).check();
await page.getByRole("textbox", { name: "Email address" }).fill("user@example.com");
Real-time QA Angle:
Manual testers often describe actions by role: "Click on Login button." This maps naturally into getByRole, making tests more intuitive and maintainable.

Common ARIA Roles:

// Button roles
getByRole("button", { name: "Submit" })
getByRole("button", { name: "Cancel" })

// Link roles  
getByRole("link", { name: "Home" })
getByRole("link", { name: "Contact" })

// Form roles
getByRole("textbox", { name: "Email" })
getByRole("textbox", { name: "Password" })
getByRole("checkbox", { name: "Remember me" })
getByRole("radio", { name: "Option 1" })
getByRole("combobox", { name: "Country" })

// Navigation roles
getByRole("navigation")
getByRole("main")
getByRole("banner")
getByRole("contentinfo")

🧑‍💻 Interactive Questions & Answers

1. Why is getByRole more stable than nth()?

Answer:
  • Semantic meaning: getByRole targets the element's purpose (button, link) rather than its position (1st, 2nd).
  • UI changes: If developers reorder elements, nth() breaks but getByRole continues to work.
  • Accessibility: getByRole aligns with how screen readers identify elements.
  • Maintenance: Less brittle when UI layout changes.
  • Example: page.locator('button').nth(0) vs page.getByRole('button', {name: 'Login'})

2. What's the difference between filter({hasText}) and getByText?

Answer:
  • filter({hasText}): Filters existing locators to find elements containing specific text. Works on a subset of elements.
  • getByText: Finds elements by their exact text content across the entire page.
  • Use case difference:
    • page.locator('.product').filter({hasText: 'Dress'}) - finds products containing "Dress"
    • page.getByText('Dress') - finds any element with exact text "Dress"
  • Precision: filter() is more precise when you know the container type.

3. If a page has two "Add to cart" buttons, how will you select the correct one?

Answer:
// Method 1: Use filter with product name
await page.locator('.product')
  .filter({ hasText: 'Printed Dress' })
  .getByRole('button', { name: 'Add to cart' })
  .click();

// Method 2: Use nth() with getByRole
await page.getByRole('button', { name: 'Add to cart' }).nth(0).click();

// Method 3: Use parent-child relationship
await page.locator('.product:has-text("Printed Dress")')
  .getByRole('button', { name: 'Add to cart' })
  .click();

// Method 4: Use data attributes (if available)
await page.locator('[data-product="printed-dress"]')
  .getByRole('button', { name: 'Add to cart' })
  .click();

4. What is the advantage of splitting into HomePage, ProductPage, CartPage?

Answer:
  • Single Responsibility: Each page object handles only its own elements and actions.
  • Maintainability: UI changes only require updating the relevant page object.
  • Reusability: Page objects can be used across multiple test files.
  • Readability: Tests read like user stories: "homePage.searchProduct()"
  • Team Collaboration: Different team members can work on different page objects.
  • Debugging: Easier to isolate issues to specific pages.
  • Scalability: Easy to add new pages and functionality.

5. Where would you assert → in POM classes or in spec files? Why?

Answer:
  • Spec files (Recommended): Keep assertions in test files for better test readability and debugging.
  • POM classes: Should contain only actions and element interactions, not assertions.
  • Exception: POM classes can have helper methods that return boolean values for assertions.
  • Example:
    // In POM class - helper method
    async isLoggedIn() {
      return await this.page.getByText("Sign out").isVisible();
    }
    
    // In spec file - assertion
    expect(await loginPage.isLoggedIn()).toBe(true);

6. How do you handle dynamic content in E2E tests?

Answer:
  • Wait for elements: Use waitForSelector() or waitFor() for dynamic content.
  • Retry mechanisms: Implement retry logic for flaky elements.
  • Data attributes: Use stable data attributes instead of dynamic IDs.
  • Text patterns: Use partial text matching for dynamic text content.
  • Example:
    // Wait for dynamic content
    await page.waitForSelector('.order-reference');
    await page.waitForFunction(() => document.querySelector('.order-reference'));
    
    // Use partial text matching
    await page.getByText(/Order #\d+/);

7. How would you handle test data management in E2E tests?

Answer:
  • Test data files: Use JSON/CSV files for test data.
  • Data factories: Create data generation utilities.
  • Database seeding: Set up test data in database before tests.
  • API calls: Use API endpoints to create test data.
  • Cleanup: Always clean up test data after tests complete.

8. What's the difference between page.goto() and page.click() for navigation?

Answer:
  • page.goto(): Direct navigation to a URL, bypasses user interactions.
  • page.click(): Simulates user clicking on a link/button, follows the actual user flow.
  • Use goto() for: Direct page access, setup, or when you need to skip certain steps.
  • Use click() for: Testing actual user interactions and navigation flows.
  • E2E preference: Use click() to test real user journeys.

🎯 Day 3 Homework Tasks

🟢 Beginner

Task 1

Extend HomePage with a method to click on "Clothes" category using getByRole("link").

Task 2

Create a test that just navigates to Clothes category and prints product names.

🟡 Intermediate

Task 3

Add a negative test: Try to checkout without ticking terms checkbox → assert error.

Task 4

Save captured OrderID into a JSON file (orders.json).

🔴 Advanced

Task 5

Create OrderHistoryPage class with method findOrder(orderId).

Task 6

Write a test: Place order (E2E) → Fetch OrderID → Navigate to Order History → Assert order is present.

Best Practices & Tips

E2E Test Design:
  • Keep tests independent - each test should be able to run in isolation
  • Use meaningful test names that describe the user journey
  • Focus on critical user paths, not every possible scenario
  • Use data attributes for stable element selection
  • Implement proper wait strategies for dynamic content
Common Pitfalls:
  • Avoid hard-coded waits - use proper wait strategies
  • Don't test implementation details - test user behavior
  • Keep tests fast - avoid unnecessary steps
  • Handle flaky elements with retry mechanisms
  • Clean up test data to avoid test interference
Performance Optimization:
  • Use session storage to avoid repeated logins
  • Run tests in parallel when possible
  • Use headless mode for faster execution
  • Implement smart waits instead of fixed delays
  • Optimize selectors for better performance

✅ Outcomes

  • Use POM across multiple pages effectively
  • Implement full E2E Order Placement flow
  • Capture and reuse OrderID for validation
  • Use getByRole & filter locators effectively
  • Handle dynamic content and test data management
  • Implement data-driven testing patterns
  • Apply best practices for maintainable E2E tests
  • Prepare for Day 4 (Dynamic Order History validation)