Let us build your first page object from scratch. We will create a LoginPage class for the Banking Portal. This is the most common page object in any project because every app has a login page.
import { type Locator, type Page } from '@playwright/test';
export class LoginPage {
// Store the page instance
readonly page: Page;
// Define all locators as readonly properties
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly forgotPasswordLink: Locator;
readonly errorMessage: Locator;
readonly rememberMeCheckbox: Locator;
constructor(page: Page) {
this.page = page;
// All locators defined in one place
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Log in' });
this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot Password' });
this.errorMessage = page.getByTestId('error-message');
this.rememberMeCheckbox = page.getByRole('checkbox', { name: 'Remember me' });
}
// Navigation
async goto() {
await this.page.goto('/banking/login');
}
// User actions
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async loginWithRememberMe(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.rememberMeCheckbox.check();
await this.loginButton.click();
}
async clickForgotPassword() {
await this.forgotPasswordLink.click();
}
// Helper to get error text
async getErrorText(): Promise<string> {
return (await this.errorMessage.textContent()) ?? '';
}
}Constructor takes page: Page -- the Playwright page instance passed from the test
Locators defined as readonly Locator properties -- set once in constructor, never change
Action methods -- represent what a user does: login(), clickForgotPassword(), addToCart()
Helper methods -- return data from the page: getErrorText(), getBalance(), getTitle()
The readonly keyword prevents accidental reassignment. If someone writes loginPage.emailInput = page.getByRole('textbox'), TypeScript throws a compile error. Locators should be defined once and never change. readonly enforces this at the language level.
Some people define locators inside methods. Do not do this. When locators are in the constructor, you see every element on the page in one glance. When a locator breaks, you know exactly where to fix it. When a new team member reads the class, they immediately understand what elements the page has.
Playwright locators are lazy. Defining them in the constructor does NOT search the DOM. The search happens only when you call an action like .fill() or .click(). So there is zero performance cost to defining all locators upfront.
Q: Walk me through how you create a page object in Playwright.
A: I create a class that takes page: Page in the constructor. All locators are defined as readonly Locator properties using Playwright's user-facing locators -- getByRole, getByLabel, getByTestId. Then I add action methods like login(email, password) that use those locators. Each method represents a user action, not a technical step. Tests create an instance and call these methods. When the UI changes, I update locators in one place. No test files change.
Key Point: A page object has four parts: constructor with page: Page, readonly locators, action methods, and helper methods. Locators go in the constructor, never inside methods.