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:
- Understanding of the page structure and elements
- Knowledge of user interactions on the page
- List of data-testid values for page elements (or ability to suggest them)
- Playwright installed in the project
- 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:
- All locators use data-testid (MANDATORY)
- Locators are readonly properties
- Constructor accepts Page object
- Include goto() method for navigation
- Action methods are async and return Promise
- Getter methods for elements that need assertions
- Use TypeScript types
- 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
- Single Responsibility: Each POM represents one page or component
- No Assertions: POMs should not contain test assertions (use getters instead)
- Encapsulation: Hide implementation details, expose high-level actions
- Reusability: Design methods to be reused across multiple tests
- Clear Naming: Use descriptive names for classes, properties, and methods
Locator Management
- data-testid Only: All locators must use data-testid attribute
- Readonly: Declare all locators as readonly
- Initialize in Constructor: All locators defined in constructor
- Descriptive Names: Use meaningful names that describe the element
- Group Related: Group related locators together (e.g., all form fields)
Method Design
- Async Methods: All action methods should be async
- Return Types: Action methods return Promise, getters return data
- Parameters: Use TypeScript types for all parameters
- Documentation: Add JSDoc comments for complex methods
- Atomic Actions: Methods should perform single, focused actions
Organization
- File Location: Store POMs in
page-objects/orpages/directory - One Class Per File: Each POM in its own file
- Export Class: Export the class as default or named export
- Index File: Consider creating index.ts for easier imports
- 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 structurecomponent-template.ts- Component-based Page Objectbase-page.ts- Base class with common functionality