Files
2025-11-30 08:28:25 +08:00

20 KiB

Page Object Builder Skill

Purpose

Create maintainable and reusable Page Object Models (POMs) for Playwright tests. Generates TypeScript classes that encapsulate page-specific locators and actions, following the Page Object Model design pattern with data-testid locators exclusively.

When to Use This Skill

Use this skill when you need to:

  • Create a Page Object Model for a specific page or component
  • Refactor tests to use the POM pattern
  • Build reusable page classes for complex applications
  • Encapsulate page-specific logic and locators
  • Improve test maintainability and reduce duplication

Do NOT use this skill when:

  • Writing simple one-off tests (use test-generator skill)
  • Debugging existing tests (use test-debugger skill)
  • Refactoring existing POMs (use test-maintainer skill)

Prerequisites

Before using this skill:

  1. Understanding of the page structure and elements
  2. Knowledge of user interactions on the page
  3. List of data-testid values for page elements (or ability to suggest them)
  4. Playwright installed in the project
  5. Basic understanding of TypeScript classes

Instructions

Step 1: Identify Page Information

Gather from the user:

  • Page name or component name
  • Page URL or route
  • Key elements on the page (buttons, inputs, text, etc.)
  • Common actions users perform on the page
  • data-testid values for all elements (or help define them)

Step 2: Plan Page Object Structure

Determine:

  • Class name (e.g., LoginPage, DashboardPage, CheckoutPage)
  • Properties: Locators for all page elements
  • Methods: Actions users can perform (login, addToCart, etc.)
  • Getters: Read-only properties for assertions
  • Navigation: How to reach this page

Step 3: Create Page Object Class

Generate a TypeScript class with:

Structure:

import { Page, Locator } from '@playwright/test';

export class PageName {
  readonly page: Page;

  // Locators
  readonly elementName: Locator;

  constructor(page: Page) {
    this.page = page;
    this.elementName = page.locator('[data-testid="element-name"]');
  }

  // Navigation
  async goto() {
    await this.page.goto('/page-url');
  }

  // Actions
  async performAction() {
    await this.elementName.click();
  }

  // Getters for assertions
  getElement() {
    return this.elementName;
  }
}

Key Requirements:

  1. All locators use data-testid (MANDATORY)
  2. Locators are readonly properties
  3. Constructor accepts Page object
  4. Include goto() method for navigation
  5. Action methods are async and return Promise
  6. Getter methods for elements that need assertions
  7. Use TypeScript types
  8. Add JSDoc comments for complex methods

Step 4: Define Locators

For each element:

readonly elementName: Locator;

constructor(page: Page) {
  this.page = page;
  this.elementName = page.locator('[data-testid="element-name"]');
}

Naming Convention:

  • Use camelCase for properties
  • Descriptive names (e.g., submitButton, emailInput, errorMessage)
  • Suffix with element type when helpful (Button, Input, Message, Link)

Step 5: Implement Action Methods

For each user action:

/**
 * Descriptive action name
 * @param param - Parameter description if needed
 */
async actionName(param?: string): Promise<void> {
  // Wait for element if needed
  await this.element.waitFor({ state: 'visible' });

  // Perform action
  await this.element.click();
  // or
  await this.element.fill(param);
}

Common Actions:

  • Form filling: async fillForm(data: FormData)
  • Button clicks: async clickButton()
  • Navigation: async navigateTo(section: string)
  • Complex workflows: async completeCheckout(details: CheckoutDetails)

Step 6: Add Getter Methods

For elements that tests will assert against:

getElementName(): Locator {
  return this.elementName;
}

async getTextContent(): Promise<string> {
  return await this.element.textContent() || '';
}

async isElementVisible(): Promise<boolean> {
  return await this.element.isVisible();
}

Step 7: Validate Page Object

Ensure the Page Object includes:

  • All locators use data-testid
  • Locators are readonly
  • All methods are async
  • Constructor accepts Page
  • goto() method for navigation
  • JSDoc comments for public methods
  • TypeScript types for parameters
  • Meaningful method names
  • No test assertions in POM (only actions and getters)

Step 8: Provide Usage Example

Show how to use the Page Object in tests:

import { test, expect } from '@playwright/test';
import { PageName } from './page-objects/PageName';

test('test description', async ({ page }) => {
  const pageName = new PageName(page);

  await pageName.goto();
  await pageName.performAction();
  await expect(pageName.getElement()).toBeVisible();
});

Examples

Example 1: Login Page Object

Input: "Create a Page Object for the login page with username, password fields, login button, and error message."

Output:

import { Page, Locator } from '@playwright/test';

/**
 * Page Object Model for the Login Page
 *
 * Handles user authentication flow including:
 * - Filling login credentials
 * - Submitting the form
 * - Accessing error messages
 */
export class LoginPage {
  readonly page: Page;

  // Locators
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;
  readonly forgotPasswordLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.locator('[data-testid="username-input"]');
    this.passwordInput = page.locator('[data-testid="password-input"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
    this.forgotPasswordLink = page.locator('[data-testid="forgot-password-link"]');
  }

  /**
   * Navigate to the login page
   */
  async goto(): Promise<void> {
    await this.page.goto('/login');
    await this.page.waitForLoadState('domcontentloaded');
  }

  /**
   * Perform login with credentials
   * @param username - User's username or email
   * @param password - User's password
   */
  async login(username: string, password: string): Promise<void> {
    await this.usernameInput.waitFor({ state: 'visible' });
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  /**
   * Fill only the username field
   */
  async fillUsername(username: string): Promise<void> {
    await this.usernameInput.fill(username);
  }

  /**
   * Fill only the password field
   */
  async fillPassword(password: string): Promise<void> {
    await this.passwordInput.fill(password);
  }

  /**
   * Click the login button
   */
  async clickLogin(): Promise<void> {
    await this.loginButton.click();
  }

  /**
   * Click forgot password link
   */
  async clickForgotPassword(): Promise<void> {
    await this.forgotPasswordLink.click();
  }

  /**
   * Get the error message element for assertions
   */
  getErrorMessage(): Locator {
    return this.errorMessage;
  }

  /**
   * Check if error message is visible
   */
  async hasError(): Promise<boolean> {
    try {
      await this.errorMessage.waitFor({ state: 'visible', timeout: 2000 });
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Get the text content of the error message
   */
  async getErrorText(): Promise<string> {
    const text = await this.errorMessage.textContent();
    return text?.trim() || '';
  }
}

Usage:

import { test, expect } from '@playwright/test';
import { LoginPage } from './page-objects/LoginPage';

test.describe('Login Flow', () => {
  test('should login successfully with valid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.login('user@example.com', 'SecurePass123');

    // Assert navigation to dashboard
    await page.waitForURL('/dashboard');
  });

  test('should show error with invalid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.login('invalid@example.com', 'wrongpass');

    // Assert error message
    await expect(loginPage.getErrorMessage()).toBeVisible();
    await expect(loginPage.getErrorMessage()).toContainText('Invalid credentials');
  });
});

Example 2: Product Page Object

Input: "Create a Page Object for a product details page with product name, price, description, quantity selector, add to cart button, and reviews section."

Output:

import { Page, Locator } from '@playwright/test';

/**
 * Page Object Model for Product Details Page
 *
 * Handles product viewing and purchasing actions including:
 * - Viewing product details
 * - Selecting quantity
 * - Adding to cart
 * - Reading reviews
 */
export class ProductPage {
  readonly page: Page;

  // Product Information Locators
  readonly productName: Locator;
  readonly productPrice: Locator;
  readonly productDescription: Locator;
  readonly productImage: Locator;

  // Purchase Locators
  readonly quantityInput: Locator;
  readonly addToCartButton: Locator;
  readonly buyNowButton: Locator;

  // Reviews Locators
  readonly reviewsSection: Locator;
  readonly reviewItems: Locator;
  readonly averageRating: Locator;

  // Additional Actions
  readonly wishlistButton: Locator;
  readonly shareButton: Locator;

  constructor(page: Page) {
    this.page = page;

    // Product information
    this.productName = page.locator('[data-testid="product-name"]');
    this.productPrice = page.locator('[data-testid="product-price"]');
    this.productDescription = page.locator('[data-testid="product-description"]');
    this.productImage = page.locator('[data-testid="product-image"]');

    // Purchase
    this.quantityInput = page.locator('[data-testid="quantity-input"]');
    this.addToCartButton = page.locator('[data-testid="add-to-cart-button"]');
    this.buyNowButton = page.locator('[data-testid="buy-now-button"]');

    // Reviews
    this.reviewsSection = page.locator('[data-testid="reviews-section"]');
    this.reviewItems = page.locator('[data-testid="review-item"]');
    this.averageRating = page.locator('[data-testid="average-rating"]');

    // Actions
    this.wishlistButton = page.locator('[data-testid="wishlist-button"]');
    this.shareButton = page.locator('[data-testid="share-button"]');
  }

  /**
   * Navigate to a product page by ID
   */
  async goto(productId: string): Promise<void> {
    await this.page.goto(`/products/${productId}`);
    await this.page.waitForLoadState('domcontentloaded');
  }

  /**
   * Set the quantity for purchase
   */
  async setQuantity(quantity: number): Promise<void> {
    await this.quantityInput.waitFor({ state: 'visible' });
    await this.quantityInput.clear();
    await this.quantityInput.fill(quantity.toString());
  }

  /**
   * Add product to cart with specified quantity
   */
  async addToCart(quantity = 1): Promise<void> {
    if (quantity > 1) {
      await this.setQuantity(quantity);
    }
    await this.addToCartButton.waitFor({ state: 'enabled' });
    await this.addToCartButton.click();
  }

  /**
   * Click Buy Now button
   */
  async buyNow(): Promise<void> {
    await this.buyNowButton.click();
  }

  /**
   * Add product to wishlist
   */
  async addToWishlist(): Promise<void> {
    await this.wishlistButton.click();
  }

  /**
   * Click share button
   */
  async shareProduct(): Promise<void> {
    await this.shareButton.click();
  }

  /**
   * Get product name text
   */
  async getProductName(): Promise<string> {
    const text = await this.productName.textContent();
    return text?.trim() || '';
  }

  /**
   * Get product price text
   */
  async getProductPrice(): Promise<string> {
    const text = await this.productPrice.textContent();
    return text?.trim() || '';
  }

  /**
   * Get number of reviews
   */
  async getReviewCount(): Promise<number> {
    return await this.reviewItems.count();
  }

  /**
   * Get average rating text
   */
  async getAverageRating(): Promise<string> {
    const text = await this.averageRating.textContent();
    return text?.trim() || '';
  }

  // Getters for assertions
  getProductNameElement(): Locator {
    return this.productName;
  }

  getProductPriceElement(): Locator {
    return this.productPrice;
  }

  getAddToCartButton(): Locator {
    return this.addToCartButton;
  }

  getReviewsSection(): Locator {
    return this.reviewsSection;
  }
}

Example 3: Dashboard Page with Multiple Sections

Input: "Create a Page Object for a dashboard with header, sidebar navigation, main content area, and user profile dropdown."

Output:

import { Page, Locator } from '@playwright/test';

/**
 * Page Object Model for Dashboard Page
 *
 * Handles dashboard navigation and interactions including:
 * - Sidebar navigation
 * - User profile actions
 * - Dashboard content
 */
export class DashboardPage {
  readonly page: Page;

  // Header Locators
  readonly header: Locator;
  readonly logo: Locator;
  readonly searchBar: Locator;
  readonly notificationIcon: Locator;
  readonly userProfileDropdown: Locator;

  // Sidebar Locators
  readonly sidebar: Locator;
  readonly homeLink: Locator;
  readonly projectsLink: Locator;
  readonly settingsLink: Locator;
  readonly logoutButton: Locator;

  // Main Content Locators
  readonly mainContent: Locator;
  readonly dashboardTitle: Locator;
  readonly statsCards: Locator;

  // Profile Dropdown Locators
  readonly profileMenu: Locator;
  readonly profileLink: Locator;
  readonly accountSettingsLink: Locator;

  constructor(page: Page) {
    this.page = page;

    // Header
    this.header = page.locator('[data-testid="dashboard-header"]');
    this.logo = page.locator('[data-testid="logo"]');
    this.searchBar = page.locator('[data-testid="search-bar"]');
    this.notificationIcon = page.locator('[data-testid="notification-icon"]');
    this.userProfileDropdown = page.locator('[data-testid="user-profile-dropdown"]');

    // Sidebar
    this.sidebar = page.locator('[data-testid="sidebar"]');
    this.homeLink = page.locator('[data-testid="nav-home"]');
    this.projectsLink = page.locator('[data-testid="nav-projects"]');
    this.settingsLink = page.locator('[data-testid="nav-settings"]');
    this.logoutButton = page.locator('[data-testid="logout-button"]');

    // Main Content
    this.mainContent = page.locator('[data-testid="main-content"]');
    this.dashboardTitle = page.locator('[data-testid="dashboard-title"]');
    this.statsCards = page.locator('[data-testid="stat-card"]');

    // Profile Dropdown
    this.profileMenu = page.locator('[data-testid="profile-menu"]');
    this.profileLink = page.locator('[data-testid="profile-link"]');
    this.accountSettingsLink = page.locator('[data-testid="account-settings-link"]');
  }

  async goto(): Promise<void> {
    await this.page.goto('/dashboard');
    await this.page.waitForLoadState('domcontentloaded');
  }

  /**
   * Navigate using sidebar
   */
  async navigateToHome(): Promise<void> {
    await this.homeLink.click();
  }

  async navigateToProjects(): Promise<void> {
    await this.projectsLink.click();
  }

  async navigateToSettings(): Promise<void> {
    await this.settingsLink.click();
  }

  /**
   * Search functionality
   */
  async search(query: string): Promise<void> {
    await this.searchBar.fill(query);
    await this.page.keyboard.press('Enter');
  }

  /**
   * Profile dropdown actions
   */
  async openProfileDropdown(): Promise<void> {
    await this.userProfileDropdown.click();
    await this.profileMenu.waitFor({ state: 'visible' });
  }

  async navigateToProfile(): Promise<void> {
    await this.openProfileDropdown();
    await this.profileLink.click();
  }

  async navigateToAccountSettings(): Promise<void> {
    await this.openProfileDropdown();
    await this.accountSettingsLink.click();
  }

  async logout(): Promise<void> {
    await this.logoutButton.click();
  }

  /**
   * Get stats count
   */
  async getStatsCount(): Promise<number> {
    return await this.statsCards.count();
  }

  // Getters for assertions
  getHeader(): Locator {
    return this.header;
  }

  getSidebar(): Locator {
    return this.sidebar;
  }

  getMainContent(): Locator {
    return this.mainContent;
  }

  getDashboardTitle(): Locator {
    return this.dashboardTitle;
  }
}

Best Practices

Page Object Design

  1. Single Responsibility: Each POM represents one page or component
  2. No Assertions: POMs should not contain test assertions (use getters instead)
  3. Encapsulation: Hide implementation details, expose high-level actions
  4. Reusability: Design methods to be reused across multiple tests
  5. Clear Naming: Use descriptive names for classes, properties, and methods

Locator Management

  1. data-testid Only: All locators must use data-testid attribute
  2. Readonly: Declare all locators as readonly
  3. Initialize in Constructor: All locators defined in constructor
  4. Descriptive Names: Use meaningful names that describe the element
  5. Group Related: Group related locators together (e.g., all form fields)

Method Design

  1. Async Methods: All action methods should be async
  2. Return Types: Action methods return Promise, getters return data
  3. Parameters: Use TypeScript types for all parameters
  4. Documentation: Add JSDoc comments for complex methods
  5. Atomic Actions: Methods should perform single, focused actions

Organization

  1. File Location: Store POMs in page-objects/ or pages/ directory
  2. One Class Per File: Each POM in its own file
  3. Export Class: Export the class as default or named export
  4. Index File: Consider creating index.ts for easier imports
  5. Naming Convention: Use PascalCase with "Page" suffix

Common Issues and Solutions

Issue 1: Too Many Locators

Problem: Page Object has 30+ locators making it hard to maintain

Solutions:

  • Break down into smaller component-based POMs
  • Group related elements into sub-objects
  • Consider component composition pattern
  • Focus on elements actually used in tests
  • Create separate POMs for complex sections

Issue 2: Tests Still Break When UI Changes

Problem: Tests fail despite using POMs

Solutions:

  • Ensure ONLY data-testid locators are used (not CSS/XPath)
  • Coordinate with developers to keep data-testid stable
  • Use semantic testid names that reflect purpose, not implementation
  • Document all required data-testid values for developers
  • Update POM centrally when testid changes

Issue 3: Duplicate Code Across POMs

Problem: Same logic repeated in multiple Page Objects

Solutions:

  • Extract common actions to utility functions
  • Create base Page class with shared methods
  • Use composition over inheritance when possible
  • Create reusable components for common UI elements
  • Consider creating a ComponentPage for shared components

Issue 4: Methods Too Complex

Problem: Action methods contain complex logic and are hard to test

Solutions:

  • Break down into smaller, atomic methods
  • Extract complex logic to private helper methods
  • Keep public methods simple and focused
  • Use composition of smaller actions
  • Add clear comments for multi-step workflows

Issue 5: Hard to Test Page Objects

Problem: Can't verify Page Object behavior without full tests

Solutions:

  • Keep POMs simple (locators + actions only)
  • Avoid business logic in POMs
  • Use type-safe interfaces
  • Create example usage in comments
  • Focus on thin wrappers over Playwright API

Resources

The resources/ directory contains templates for common patterns:

  • page-template.ts - Basic Page Object structure
  • component-template.ts - Component-based Page Object
  • base-page.ts - Base class with common functionality