🕒 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).
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.
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)vspage.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);
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()orwaitFor()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)