Page Object Advanced
Why This Matters
Basic page objects work for simple applications, but production systems face scalability challenges. A typical enterprise application has hundreds of pages sharing common components (navigation bars, modals, forms, data grids). Without advanced patterns, teams end up duplicating selectors and logic across page objects, creating maintenance nightmares when the design system changes. When your marketing team redesigns the navigation bar used on 80 pages, you shouldn’t need to update 80 page objects.
Advanced page object patterns solve this through component-based composition. Instead of monolithic page objects containing all selectors and logic, you create reusable component objects representing UI elements shared across pages. Your page objects compose these components, gaining consistency and reducing duplication. When the navigation bar changes, you update one component object instead of dozens of page objects. This architectural shift mirrors modern frontend development—composing pages from reusable components.
Production applications also require dynamic page objects for data-driven scenarios. E-commerce sites with thousands of product pages can’t maintain individual page objects for each SKU. Dynamic page objects use parameterized locators and methods, adapting to runtime data while maintaining type safety and encapsulation. These patterns enable test scalability without sacrificing maintainability, allowing teams to test diverse scenarios without exponential growth in page object code.
Standard Library Approach: Basic Page Objects
Playwright provides the foundation for page objects through its core APIs—no additional framework needed. Here’s a standard page object implementation:
// pages/ProductPage.ts
import { Page, Locator } from "@playwright/test";
export class ProductPage {
// => Declares ProductPage class encapsulating product page
// => Stores page instance as private field
readonly page: Page;
// => Declares locators for page elements
// => Readonly ensures locators cannot be reassigned
readonly productTitle: Locator;
readonly addToCartButton: Locator;
readonly quantityInput: Locator;
readonly priceLabel: Locator;
constructor(page: Page) {
// => Constructor receives Page instance from test
// => Initializes all locators during construction
this.page = page;
this.productTitle = page.locator('[data-testid="product-title"]');
// => data-testid selector provides stable identifier
// => Locator created but not evaluated until action called
this.addToCartButton = page.locator('button:text("Add to Cart")');
// => Text selector matches button by visible text
// => Automatically waits for element during action
this.quantityInput = page.locator("#quantity");
// => ID selector for form input field
// => Fastest selector type but requires stable IDs
this.priceLabel = page.locator(".price-display");
// => Class selector for price display element
// => Returns first matching element
}
async goto(productId: string) {
// => Navigation method encapsulates URL construction
// => Accepts productId parameter for dynamic routing
await this.page.goto(`/products/${productId}`);
// => Navigates to product detail page
// => Waits for load event before returning
}
async addToCart(quantity: number = 1) {
// => High-level business action method
// => Default quantity of 1 for typical case
await this.quantityInput.fill(quantity.toString());
// => Fills quantity input with string value
// => Playwright auto-waits for element to be actionable
await this.addToCartButton.click();
// => Clicks add to cart button
// => Waits for button to be visible and enabled
}
async getPrice(): Promise<string> {
// => Retrieves price text from page
// => Returns Promise<string> for async operation
return (await this.priceLabel.textContent()) ?? "";
// => Gets text content of price label
// => Null coalescing operator handles null case
}
}Limitations for production:
- Component duplication: Navigation bar, footer, modals duplicated across every page object that uses them
- Inconsistent updates: Changing shared component (like header) requires updating all page objects that include it
- Monolithic growth: Page objects become large as pages gain more components (forms, grids, dialogs)
- No composition: Cannot reuse common component logic across different pages
- Dynamic scenarios: Creating page objects for data-driven pages (thousands of products) impractical
- Type safety issues: Parameterized behavior (filtering, sorting) requires stringly-typed methods
Production Pattern: Component Objects with Composition
Production applications solve these limitations through component-based architecture. Extract reusable UI components into dedicated component objects, then compose them into page objects:
// components/NavigationBar.ts
import { Page, Locator } from "@playwright/test";
export class NavigationBar {
// => Component object represents navigation bar UI component
// => Encapsulates all selectors and behaviors for navigation
readonly page: Page;
private readonly container: Locator;
// => Component locators scoped within navigation container
// => Ensures selectors only match within this component
readonly searchInput: Locator;
readonly cartIcon: Locator;
readonly userMenu: Locator;
readonly loginLink: Locator;
constructor(page: Page) {
// => Constructor receives Page instance
// => Locates navigation bar container as root
this.page = page;
this.container = page.locator('[data-testid="main-navigation"]');
// => Container provides scope for child locators
// => Prevents selector conflicts with page body
// => All component selectors chained from container
// => Scoping ensures selectors only match within component
this.searchInput = this.container.locator('input[type="search"]');
this.cartIcon = this.container.locator('[data-testid="cart-icon"]');
this.userMenu = this.container.locator('[data-testid="user-menu"]');
this.loginLink = this.container.locator('a:text("Login")');
}
async search(query: string): Promise<void> {
// => High-level component action method
// => Encapsulates search interaction sequence
await this.searchInput.fill(query);
// => Fills search input with query text
// => Auto-waits for input to be visible and enabled
await this.searchInput.press("Enter");
// => Submits search by pressing Enter key
// => Triggers navigation to search results page
}
async getCartItemCount(): Promise<number> {
// => Retrieves cart item count from badge
// => Returns numeric count for assertions
const badge = this.cartIcon.locator(".badge");
// => Locates badge element within cart icon
// => Badge displays item count
const text = await badge.textContent();
// => Gets badge text content
// => Returns null if badge not found
return text ? parseInt(text, 10) : 0;
// => Parses text to number with base 10
// => Defaults to 0 if badge missing or text null
}
async login(username: string, password: string): Promise<void> {
// => Complete login flow from navigation bar
// => Encapsulates multi-step interaction
await this.userMenu.click();
// => Opens user menu dropdown
// => Waits for menu to be visible
await this.loginLink.click();
// => Navigates to login page
// => Waits for navigation to complete
await this.page.locator("#username").fill(username);
// => Fills username field on login page
// => Uses page-level locator for login form
await this.page.locator("#password").fill(password);
// => Fills password field
// => Form fields outside component scope
await this.page.locator('button:text("Submit")').click();
// => Submits login form
// => Waits for submission to complete
}
}// components/ProductCard.ts
import { Locator, Page } from "@playwright/test";
export class ProductCard {
// => Component representing individual product card
// => Reusable across catalog, search results, recommendations
readonly page: Page;
private readonly container: Locator;
// => Card-specific locators scoped to this card instance
// => Each card instance has independent locators
readonly title: Locator;
readonly price: Locator;
readonly image: Locator;
readonly addToCartButton: Locator;
readonly ratingStars: Locator;
constructor(page: Page, container: Locator) {
// => Constructor accepts both Page and container Locator
// => Container passed from parent allows dynamic scoping
this.page = page;
this.container = container;
// => Container represents this specific card's root element
// => Enables multiple card instances on same page
// => All locators scoped to this card's container
// => Prevents interactions with other cards on page
this.title = this.container.locator('[data-testid="product-title"]');
this.price = this.container.locator('[data-testid="product-price"]');
this.image = this.container.locator("img.product-image");
this.addToCartButton = this.container.locator('button:text("Add to Cart")');
this.ratingStars = this.container.locator('[data-testid="rating-stars"]');
}
async getTitle(): Promise<string> {
// => Retrieves product title text
// => Returns empty string if title missing
return (await this.title.textContent()) ?? "";
}
async getPrice(): Promise<number> {
// => Retrieves numeric price value
// => Parses currency-formatted text to number
const text = (await this.price.textContent()) ?? "";
// => Gets price text like "$29.99"
// => Null coalescing handles missing element
return parseFloat(text.replace(/[^0-9.]/g, ""));
// => Removes non-numeric characters except decimal
// => Parses remaining string to float
}
async addToCart(): Promise<void> {
// => Adds this product to cart
// => Encapsulates card-level cart action
await this.addToCartButton.click();
// => Clicks add to cart button for this card
// => Waits for button to be clickable
}
async getRating(): Promise<number> {
// => Retrieves product rating value
// => Parses star count or rating attribute
const ratingText = await this.ratingStars.getAttribute("aria-label");
// => Gets aria-label like "4.5 out of 5 stars"
// => Accessibility attribute provides reliable data
const match = ratingText?.match(/(\d+\.?\d*)/);
// => Regex extracts numeric rating value
// => Handles decimal ratings like 4.5
return match ? parseFloat(match[1]) : 0;
// => Returns parsed rating or 0 if not found
// => Provides numeric value for assertions
}
async isAvailable(): Promise<boolean> {
// => Checks if product is available for purchase
// => Returns boolean for availability status
return await this.addToCartButton.isEnabled();
// => Checks if add to cart button is enabled
// => Disabled button indicates out of stock
}
}// pages/CatalogPage.ts - Composing Component Objects
import { Page } from "@playwright/test";
import { NavigationBar } from "../components/NavigationBar";
import { ProductCard } from "../components/ProductCard";
export class CatalogPage {
// => Page object composes reusable components
// => Delegates navigation bar behavior to component
readonly page: Page;
readonly navigation: NavigationBar;
// => NavigationBar component handles all nav interactions
// => Page object doesn't duplicate nav selectors/logic
constructor(page: Page) {
// => Constructor initializes page and component objects
// => Composition happens during construction
this.page = page;
this.navigation = new NavigationBar(page);
// => Creates NavigationBar component instance
// => Component shares same Page instance
}
async goto(category?: string): Promise<void> {
// => Navigates to catalog page
// => Optional category parameter for filtered catalog
const url = category ? `/catalog?category=${category}` : "/catalog";
// => Constructs URL with optional query parameter
// => Defaults to full catalog if no category
await this.page.goto(url);
// => Navigates to catalog URL
// => Waits for page load before returning
}
async getProductCards(): Promise<ProductCard[]> {
// => Retrieves array of ProductCard component instances
// => Creates one component per card on page
const cardLocators = await this.page.locator('[data-testid="product-card"]').all();
// => Finds all product card elements on page
// => Returns array of Locators for each card
return cardLocators.map((locator) => new ProductCard(this.page, locator));
// => Maps each Locator to ProductCard instance
// => Each card scoped to its container Locator
// => Returns array of independent ProductCard objects
}
async getProductCard(index: number): Promise<ProductCard> {
// => Retrieves specific ProductCard by index
// => Enables targeted interaction with nth card
const cardLocator = this.page.locator('[data-testid="product-card"]').nth(index);
// => Gets nth product card element
// => Zero-based index (0 = first card)
return new ProductCard(this.page, cardLocator);
// => Creates ProductCard scoped to specific card
// => Returns independent component instance
}
async filterByPrice(minPrice: number, maxPrice: number): Promise<void> {
// => Applies price range filter to catalog
// => Encapsulates filter interaction
await this.page.locator("#price-min").fill(minPrice.toString());
// => Fills minimum price input
// => Converts number to string for input
await this.page.locator("#price-max").fill(maxPrice.toString());
// => Fills maximum price input
// => Both inputs required for range filter
await this.page.locator('button:text("Apply Filters")').click();
// => Applies filter by clicking button
// => Waits for filtered results to load
}
async sortBy(option: "price-asc" | "price-desc" | "rating" | "newest"): Promise<void> {
// => Applies sort order to catalog
// => Type-safe sorting options via union type
await this.page.locator("#sort-select").selectOption(option);
// => Selects sort option from dropdown
// => Triggers catalog re-ordering
// => Union type prevents invalid sort values
}
}Architecture: Component-Based Page Objects
graph TD
TestSuite["Test Suite"]
CatalogPage["CatalogPage"]
CheckoutPage["CheckoutPage"]
ProductPage["ProductPage"]
NavBar["NavigationBar Component"]
ProductCard["ProductCard Component"]
FormValidator["FormValidator Component"]
Modal["Modal Component"]
TestSuite -->|"composes"| CatalogPage
TestSuite -->|"composes"| CheckoutPage
TestSuite -->|"composes"| ProductPage
CatalogPage -->|"reuses"| NavBar
CatalogPage -->|"creates multiple"| ProductCard
CheckoutPage -->|"reuses"| NavBar
CheckoutPage -->|"reuses"| FormValidator
CheckoutPage -->|"reuses"| Modal
ProductPage -->|"reuses"| NavBar
ProductPage -->|"reuses"| Modal
style TestSuite fill:#0173B2,stroke:#000,stroke-width:2px,color:#fff
style CatalogPage fill:#029E73,stroke:#000,stroke-width:2px,color:#fff
style CheckoutPage fill:#029E73,stroke:#000,stroke-width:2px,color:#fff
style ProductPage fill:#029E73,stroke:#000,stroke-width:2px,color:#fff
style NavBar fill:#DE8F05,stroke:#000,stroke-width:2px,color:#000
style ProductCard fill:#DE8F05,stroke:#000,stroke-width:2px,color:#000
style FormValidator fill:#DE8F05,stroke:#000,stroke-width:2px,color:#000
style Modal fill:#DE8F05,stroke:#000,stroke-width:2px,color:#000
classDef testClass fill:#0173B2,stroke:#000,stroke-width:2px,color:#fff
classDef pageClass fill:#029E73,stroke:#000,stroke-width:2px,color:#fff
classDef componentClass fill:#DE8F05,stroke:#000,stroke-width:2px,color:#000
Accessibility Note: Diagram uses color-blind friendly palette—blue for tests, teal for pages, orange for components.
Production Patterns and Best Practices
Pattern 1: Fluent Interface for Page Objects
Fluent interfaces enable method chaining, creating readable test code that resembles natural language:
// pages/CheckoutPage.ts
import { Page } from "@playwright/test";
export class CheckoutPage {
// => Fluent interface page object with method chaining
// => Each method returns 'this' enabling chaining
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(): Promise<this> {
// => Navigation returns 'this' for chaining
// => Enables: await new CheckoutPage(page).goto().fillShipping(...)
await this.page.goto("/checkout");
return this;
// => Returns page object instance
// => Allows immediate method chaining
}
async fillShippingAddress(address: ShippingAddress): Promise<this> {
// => Fills shipping form and returns this
// => Fluent method enables chaining multiple form fills
await this.page.locator("#shipping-name").fill(address.name);
await this.page.locator("#shipping-street").fill(address.street);
await this.page.locator("#shipping-city").fill(address.city);
await this.page.locator("#shipping-postal").fill(address.postalCode);
// => Fills all shipping address fields
// => Sequential fills ensure correct form population
return this;
// => Returns this for next chained method
// => Enables: .fillShippingAddress(...).selectPaymentMethod(...)
}
async selectPaymentMethod(method: "credit" | "debit" | "paypal"): Promise<this> {
// => Selects payment method and returns this
// => Type-safe payment method via union type
await this.page.locator(`#payment-${method}`).click();
// => Clicks radio button for payment method
// => Dynamic selector based on method parameter
return this;
// => Chaining continues to next operation
}
async applyPromoCode(code: string): Promise<this> {
// => Applies promo code and returns this
// => Optional step in checkout flow
await this.page.locator("#promo-code").fill(code);
await this.page.locator('button:text("Apply")').click();
// => Fills promo code input and submits
// => Waits for application to complete
return this;
// => Returns this for continued chaining
}
async submitOrder(): Promise<OrderConfirmationPage> {
// => Final action returns different page object
// => Fluent chain terminates, returns next page
await this.page.locator('button:text("Place Order")').click();
// => Submits checkout form
// => Triggers navigation to confirmation page
return new OrderConfirmationPage(this.page);
// => Creates and returns next page object
// => Continues page object chain across navigation
}
}
// Type definitions
interface ShippingAddress {
name: string;
street: string;
city: string;
postalCode: string;
}
// Usage in test - readable fluent chain
test("complete checkout with promo code", async ({ page }) => {
const confirmationPage = await new CheckoutPage(page)
.goto()
// => Navigates to checkout page
.fillShippingAddress({
name: "John Doe",
street: "123 Main St",
city: "Portland",
postalCode: "97201",
})
// => Fills shipping address form
.selectPaymentMethod("credit")
// => Selects credit card payment
.applyPromoCode("SAVE20")
// => Applies promotional code
.submitOrder();
// => Submits order, returns confirmation page
// => Chain reads like: "go to checkout, fill shipping, select payment, apply promo, submit"
// => Fluent interface creates self-documenting test code
});Pattern 2: Dynamic Page Objects with Parameterization
Production applications often have thousands of similar pages (products, articles, profiles). Dynamic page objects handle this through parameterization:
// pages/DynamicProductPage.ts
import { Page, Locator } from "@playwright/test";
export class DynamicProductPage {
// => Dynamic page object for product pages
// => Adapts to any product via productId parameter
readonly page: Page;
private readonly productId: string;
// => Locators use dynamic selectors based on productId
// => Enables single page object for all products
private get productContainer(): Locator {
// => Getter provides dynamic locator per instance
// => Returns locator scoped to specific product
return this.page.locator(`[data-product-id="${this.productId}"]`);
// => Attribute selector matches specific product container
// => Scopes all child locators to this product
}
constructor(page: Page, productId: string) {
// => Constructor accepts productId parameter
// => Creates page object instance for specific product
this.page = page;
this.productId = productId;
// => Stores productId for dynamic locator generation
}
async goto(): Promise<void> {
// => Navigates to product page using stored productId
// => Constructs URL dynamically
await this.page.goto(`/products/${this.productId}`);
// => Dynamic URL based on instance's productId
// => Enables: new DynamicProductPage(page, "SKU123").goto()
}
async getTitle(): Promise<string> {
// => Retrieves product title
// => Uses dynamic container scoping
return (await this.productContainer.locator('[data-testid="title"]').textContent()) ?? "";
// => Scopes title locator to this product's container
// => Prevents matching titles from other products
}
async selectVariant(option: string, value: string): Promise<void> {
// => Selects product variant (size, color, etc.)
// => Dynamic selection based on option and value
const selector = `[data-option="${option}"][data-value="${value}"]`;
// => Constructs selector matching specific variant
// => Example: data-option="size" data-value="large"
await this.productContainer.locator(selector).click();
// => Clicks variant option within product container
// => Scoping ensures correct product variant selected
}
async addToCart(quantity: number = 1): Promise<void> {
// => Adds product to cart with specified quantity
// => Defaults to quantity of 1
await this.productContainer.locator('input[name="quantity"]').fill(quantity.toString());
// => Fills quantity input within product container
// => Converts number to string for input field
await this.productContainer.locator('button:text("Add to Cart")').click();
// => Clicks add to cart button for this product
// => Scoped click prevents adding wrong product
}
}
// Factory pattern for creating product page objects
export class ProductPageFactory {
// => Factory creates product page objects dynamically
// => Centralizes page object instantiation
constructor(private page: Page) {
// => Stores Page instance for factory use
// => All created page objects share same page
}
createProductPage(productId: string): DynamicProductPage {
// => Factory method creates product page instance
// => Accepts productId parameter for dynamic behavior
return new DynamicProductPage(this.page, productId);
// => Returns new page object instance
// => Each instance encapsulates specific product
}
async createFromUrl(url: string): Promise<DynamicProductPage> {
// => Creates page object by parsing URL
// => Extracts productId from URL structure
const match = url.match(/\/products\/([^/]+)/);
// => Regex extracts productId from URL path
// => Matches pattern: /products/{productId}
if (!match) {
throw new Error(`Invalid product URL: ${url}`);
}
// => Validates URL contains productId
// => Throws error for malformed URLs
const productId = match[1];
// => Extracts captured productId from regex match
// => First capture group contains product identifier
return this.createProductPage(productId);
// => Creates and returns page object for extracted ID
// => Enables: factory.createFromUrl(currentUrl)
}
}
// Usage in test - testing multiple products
test("add multiple products to cart", async ({ page }) => {
const factory = new ProductPageFactory(page);
// => Creates factory for generating product pages
// => Reuses factory for multiple products
const products = ["SKU001", "SKU002", "SKU003"];
// => Array of product IDs to test
// => Demonstrates scalable testing approach
for (const productId of products) {
const productPage = factory.createProductPage(productId);
// => Creates page object for current product
// => New instance per product iteration
await productPage.goto();
// => Navigates to product page
// => Dynamic URL: /products/SKU001, etc.
await productPage.selectVariant("size", "large");
await productPage.addToCart(2);
// => Selects size and adds 2 units
// => Same code works for all products
}
// => Loop tests three products without three separate page objects
// => Dynamic page objects enable scalable product testing
});Pattern 3: Component Objects with State Management
Advanced component objects track state changes and provide validation:
// components/ShoppingCart.ts
import { Page, Locator } from "@playwright/test";
export interface CartItem {
productId: string;
name: string;
quantity: number;
price: number;
}
export class ShoppingCart {
// => Stateful component tracking cart contents
// => Provides validation and assertions
readonly page: Page;
private readonly container: Locator;
private cachedItems: CartItem[] | null = null;
// => Cached cart items for performance
// => Null indicates cache needs refresh
constructor(page: Page) {
this.page = page;
this.container = page.locator('[data-testid="shopping-cart"]');
// => Component container for cart UI
// => Scopes all cart interactions
}
async open(): Promise<void> {
// => Opens cart drawer or navigates to cart page
// => Waits for cart to be visible
await this.page.locator('[data-testid="cart-icon"]').click();
// => Clicks cart icon to open
// => Triggers cart drawer slide-in
await this.container.waitFor({ state: "visible" });
// => Waits for cart container to appear
// => Ensures cart fully loaded before proceeding
this.invalidateCache();
// => Clears cached items after opening
// => Forces fresh read of cart contents
}
async getItems(): Promise<CartItem[]> {
// => Retrieves all items in cart
// => Returns structured array of cart items
if (this.cachedItems) {
return this.cachedItems;
// => Returns cached items if available
// => Avoids redundant DOM queries
}
const itemElements = await this.container.locator('[data-testid="cart-item"]').all();
// => Finds all cart item elements
// => Returns array of item Locators
const items: CartItem[] = [];
for (const element of itemElements) {
// => Iterates each cart item element
// => Extracts structured data from DOM
const productId = (await element.getAttribute("data-product-id")) ?? "";
// => Reads product ID from data attribute
// => Unique identifier for cart item
const name = (await element.locator('[data-testid="item-name"]').textContent()) ?? "";
// => Extracts product name from cart item
// => Display name shown to user
const quantityText = (await element.locator('[data-testid="item-quantity"]').textContent()) ?? "0";
// => Extracts quantity text from cart item
// => Defaults to '0' if not found
const quantity = parseInt(quantityText, 10);
// => Parses quantity string to number
// => Base 10 parsing for decimal integers
const priceText = (await element.locator('[data-testid="item-price"]').textContent()) ?? "$0";
// => Extracts price text like "$29.99"
// => Defaults to '$0' if missing
const price = parseFloat(priceText.replace(/[^0-9.]/g, ""));
// => Removes currency symbols and parses to float
// => Handles various currency formats
items.push({ productId, name, quantity, price });
// => Adds structured item to array
// => Normalized data for testing
}
this.cachedItems = items;
// => Caches items for subsequent calls
// => Reduces DOM queries within same test step
return items;
// => Returns complete array of cart items
// => Ready for assertions and validation
}
async getTotalPrice(): Promise<number> {
// => Calculates total cart price
// => Returns numeric total for assertions
const items = await this.getItems();
// => Retrieves all cart items
// => Uses cached items if available
return items.reduce((sum, item) => {
return sum + item.quantity * item.price;
}, 0);
// => Sums quantity × price for each item
// => Reduces to single total value
// => Starts with 0 accumulator
}
async removeItem(productId: string): Promise<void> {
// => Removes item from cart by product ID
// => Invalidates cache after removal
const itemElement = this.container.locator(`[data-testid="cart-item"][data-product-id="${productId}"]`);
// => Locates specific cart item by product ID
// => Scoped to cart container
await itemElement.locator('button[aria-label="Remove"]').click();
// => Clicks remove button for item
// => aria-label provides accessible selector
this.invalidateCache();
// => Clears cache after cart modification
// => Forces fresh read on next getItems()
}
async updateQuantity(productId: string, quantity: number): Promise<void> {
// => Updates item quantity in cart
// => Invalidates cache after update
const itemElement = this.container.locator(`[data-testid="cart-item"][data-product-id="${productId}"]`);
// => Locates cart item element
// => Uses product ID for precise selection
await itemElement.locator('input[name="quantity"]').fill(quantity.toString());
// => Updates quantity input field
// => Converts number to string for input
await itemElement.locator('button[aria-label="Update"]').click();
// => Clicks update button to apply change
// => Waits for cart to recalculate
this.invalidateCache();
// => Clears cache after quantity change
// => Next getItems() reads updated cart
}
private invalidateCache(): void {
// => Clears cached items
// => Forces fresh DOM read on next access
this.cachedItems = null;
// => Sets cache to null
// => getItems() will re-query DOM
}
async assertItemCount(expectedCount: number): Promise<void> {
// => Assertion helper for item count
// => Provides clear error messages
const items = await this.getItems();
if (items.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} items in cart, found ${items.length}`);
}
// => Throws descriptive error on mismatch
// => Includes expected and actual counts
}
async assertContainsProduct(productId: string): Promise<void> {
// => Assertion helper for product presence
// => Validates product exists in cart
const items = await this.getItems();
const hasProduct = items.some((item) => item.productId === productId);
// => Checks if any cart item matches product ID
// => Returns boolean for presence check
if (!hasProduct) {
throw new Error(`Cart does not contain product: ${productId}`);
}
// => Throws error if product not in cart
// => Provides failing product ID for debugging
}
}
// Usage in test - stateful cart validation
test("cart state management", async ({ page }) => {
const cart = new ShoppingCart(page);
await cart.open();
// => Opens cart component
// => Initializes stateful component
await cart.assertItemCount(0);
// => Verifies cart starts empty
// => Component provides high-level assertion
// Add products (via other page objects, not shown)
// ...
await cart.assertItemCount(2);
await cart.assertContainsProduct("SKU001");
// => Validates cart contains expected products
// => Component tracks and validates state
const total = await cart.getTotalPrice();
expect(total).toBeGreaterThan(0);
// => Component calculates total from cached items
// => Efficient validation without multiple DOM queries
});Trade-offs and When to Use
Standard Page Objects:
- Use when: Small applications (< 20 pages), minimal component reuse, prototype tests
- Benefits: Simple implementation, no abstraction overhead, easy to understand
- Costs: Duplication across page objects, inconsistent updates, monolithic growth
Component-Based Page Objects:
- Use when: Medium to large applications (20+ pages), shared UI components, production test suites
- Benefits: Component reuse, consistent updates, maintainable at scale
- Costs: Additional abstraction layer, more files to manage, upfront design effort
Dynamic Page Objects:
- Use when: Data-driven pages (products, articles, profiles), parameterized content
- Benefits: Single page object for many pages, scalable testing, reduced code duplication
- Costs: Complexity in parameterization, potential for over-abstraction
Fluent Interfaces:
- Use when: Multi-step workflows, readable test code prioritized, complex interactions
- Benefits: Self-documenting tests, reduced variable assignments, readable chains
- Costs: All methods must return ’this’, error handling in chains complex
Production recommendation: Use component-based page objects for applications with shared UI components. Start with simple page objects for prototypes, refactor to components when duplication emerges. Dynamic page objects solve specific data-driven scenarios—don’t force all pages into dynamic pattern. Fluent interfaces enhance readability but aren’t required—use when multi-step workflows common.
Security Considerations
- Sensitive data exposure: Never log passwords, API keys, or PII in page object methods. Component objects that handle credentials should use secure parameter passing without logging values.
- XSS prevention in selectors: Avoid constructing selectors from user input. Dynamic page objects using URL parameters should validate/sanitize input before building selectors to prevent injection attacks.
- Test data isolation: Component objects accessing shared resources (databases, APIs) must not leak test data between tests. Implement cleanup methods that run in teardown hooks.
- Authentication state: Page objects managing authentication should store tokens securely in browser context, not instance variables. Use Playwright’s
storageStatefor session persistence across tests. - API key injection: Component objects making API calls should receive credentials via environment variables, never hardcoded. Factory patterns should inject configuration from centralized secure sources.
Common Pitfalls
Over-abstracting components: Creating component objects for every UI element, even non-reusable ones, adds unnecessary complexity. Extract components only when reused across 3+ page objects or when encapsulation significantly improves maintainability.
Tight coupling between components: Component objects calling methods on other component objects create fragile dependencies. Page objects should orchestrate component interactions, not components themselves.
Stateful components without invalidation: Caching component state (like cart items) without proper cache invalidation leads to stale data in tests. Always invalidate cache after mutations (add, remove, update) and consider disabling cache by default.
Ignoring component boundaries: Locators in component objects should strictly scope to component container, never selecting elements outside component. Violations break encapsulation and cause selector conflicts when multiple instances exist.
Dynamic page objects for static content: Using parameterized page objects for pages without dynamic behavior adds unnecessary complexity. Static pages (About Us, Contact) benefit from simple page objects, not dynamic patterns.
Fluent methods with side effects: Fluent interface methods returning ’this’ should complete actions atomically. Methods that trigger navigation or API calls mid-chain cause unexpected behavior when chaining fails midway.
Missing TypeScript types for components: Component interfaces and page object types enable refactoring safety. Omitting types loses benefits of TypeScript—autocomplete, type checking, and interface contracts.
Component proliferation without documentation: As component library grows, team loses track of available components. Maintain component catalog documentation listing all component objects, their responsibilities, and usage examples.