Now we build the actual page objects. Each class encapsulates all interactions with one page. When the UI changes, you update one class -- not 20 test files. Let us start with the most critical pages.
import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base-page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorMessage: Locator;
readonly signUpLink: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.getByRole('alert');
this.signUpLink = page.getByRole('link', { name: 'Sign Up' });
}
async goto() {
await this.page.goto('/login');
await expect(this.signInButton).toBeVisible();
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
async expectLoggedIn() {
await expect(this.page).not.toHaveURL(/\/login/);
}
}import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base-page';
export class ProductListPage extends BasePage {
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly productCards: Locator;
readonly categoryFilter: Locator;
readonly priceFilter: Locator;
readonly sortDropdown: Locator;
readonly noResultsMessage: Locator;
readonly cartIcon: Locator;
constructor(page: Page) {
super(page);
this.searchInput = page.getByPlaceholder('Search products');
this.searchButton = page.getByRole('button', { name: 'Search' });
this.productCards = page.locator('[data-testid="product-card"]');
this.categoryFilter = page.getByLabel('Category');
this.priceFilter = page.getByLabel('Price Range');
this.sortDropdown = page.getByLabel('Sort by');
this.noResultsMessage = page.getByText('No products found');
this.cartIcon = page.locator('[data-testid="cart-icon"]');
}
async goto() {
await this.page.goto('/shopping');
await this.waitForPageLoad();
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchButton.click();
await this.waitForPageLoad();
}
async filterByCategory(category: string) {
await this.categoryFilter.selectOption(category);
await this.waitForPageLoad();
}
async getProductCount(): Promise<number> {
return this.productCards.count();
}
async openProduct(index: number) {
await this.productCards.nth(index).click();
await this.waitForPageLoad();
}
async openCart() {
await this.cartIcon.click();
}
async expectLoaded() {
await expect(this.searchInput).toBeVisible();
}
async expectNoResults() {
await expect(this.noResultsMessage).toBeVisible();
}
}import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base-page';
export class CartPage extends BasePage {
readonly cartItems: Locator;
readonly emptyMessage: Locator;
readonly checkoutButton: Locator;
readonly totalPrice: Locator;
readonly continueShoppingLink: Locator;
constructor(page: Page) {
super(page);
this.cartItems = page.locator('[data-testid="cart-item"]');
this.emptyMessage = page.getByText('Your cart is empty');
this.checkoutButton = page.getByRole('button', { name: 'Checkout' });
this.totalPrice = page.locator('[data-testid="cart-total"]');
this.continueShoppingLink = page.getByRole('link', { name: 'Continue Shopping' });
}
async goto() {
await this.page.goto('/shopping/cart');
await this.waitForPageLoad();
}
async getItemCount(): Promise<number> {
return this.cartItems.count();
}
async removeItem(index: number) {
await this.cartItems.nth(index)
.getByRole('button', { name: 'Remove' })
.click();
await this.waitForPageLoad();
}
async updateQuantity(index: number, quantity: number) {
const item = this.cartItems.nth(index);
const qtyInput = item.getByLabel('Quantity');
await qtyInput.fill(String(quantity));
await qtyInput.press('Tab');
await this.waitForPageLoad();
}
async checkout() {
await this.checkoutButton.click();
await this.waitForPageLoad();
}
async expectEmpty() {
await expect(this.emptyMessage).toBeVisible();
await expect(this.checkoutButton).not.toBeVisible();
}
async getTotalText(): Promise<string> {
return (await this.totalPrice.textContent()) || '';
}
}import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base-page';
interface ShippingInfo {
name: string;
address: string;
city: string;
state: string;
zip: string;
phone: string;
}
interface PaymentInfo {
cardNumber: string;
expiry: string;
cvv: string;
}
export class CheckoutPage extends BasePage {
readonly confirmationMessage: Locator;
readonly orderId: Locator;
readonly placeOrderButton: Locator;
readonly validationErrors: Locator;
constructor(page: Page) {
super(page);
this.confirmationMessage = page.getByText('Order Confirmed');
this.orderId = page.locator('[data-testid="order-id"]');
this.placeOrderButton = page.getByRole('button', { name: 'Place Order' });
this.validationErrors = page.locator('.field-error');
}
async fillShipping(info: ShippingInfo) {
await this.page.getByLabel('Full Name').fill(info.name);
await this.page.getByLabel('Address').fill(info.address);
await this.page.getByLabel('City').fill(info.city);
await this.page.getByLabel('State').fill(info.state);
await this.page.getByLabel('ZIP Code').fill(info.zip);
await this.page.getByLabel('Phone').fill(info.phone);
}
async fillPayment(info: PaymentInfo) {
await this.page.getByLabel('Card Number').fill(info.cardNumber);
await this.page.getByLabel('Expiry').fill(info.expiry);
await this.page.getByLabel('CVV').fill(info.cvv);
}
async placeOrder() {
await this.placeOrderButton.click();
}
async expectConfirmation() {
await expect(this.confirmationMessage).toBeVisible();
await expect(this.orderId).toBeVisible();
}
async expectValidationErrors(count: number) {
await expect(this.validationErrors).toHaveCount(count);
}
}Notice how each page object uses getByRole and getByLabel instead of CSS selectors. This makes tests resilient to CSS changes. If a developer renames a class from .btn-primary to .btn-submit, your tests still work because the button role and name did not change.
Q: What are the key design principles for building maintainable Page Objects?
A: Five principles: (1) One class per page for clear ownership. (2) Locators are defined in the constructor using semantic selectors (getByRole, getByLabel). (3) Methods represent user actions (login, addToCart) not technical operations (clickButton). (4) No assertions inside page objects -- keep them in test files so tests clearly show what is being verified. (5) Use a BasePage class for shared behavior like waitForLoad and takeScreenshot. The goal: when the UI changes, update one page object file -- not 20 test files.
Key Point: Page objects encapsulate page interactions with semantic locators in the constructor, action methods returning void, and no assertions. One class per page, one update when UI changes.
Key Point: Build page objects with semantic locators, action methods, no assertions, and a base page class for shared behavior