Playwright Page Object Model
A well-organized toolbox keeps every screwdriver and wrench in its own labeled compartment, so a repair job never turns into a frustrating search through a messy pile of tools. The Page Object Model organizes test code the same way, giving every page on a website its own dedicated, labeled file.
The Problem Without Page Objects
Imagine fifty test files, each containing its own copy of the login page's locators. A developer renames the password field's ID, and now fifty separate files need the exact same manual fix, an error-prone and exhausting task.
Without Page Objects Versus With Page Objects
Without Page Objects: Test File 1 -> repeats login locators Test File 2 -> repeats login locators Test File 3 -> repeats login locators (one broken locator breaks all three) With Page Objects: LoginPage Class -> holds login locators once Test File 1, 2, 3 -> all reuse the same class (one fix in LoginPage repairs every test instantly)
Creating a Page Object Class
class LoginPage {
constructor(page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Log In' });
this.errorMessage = page.getByText('Invalid credentials');
}
async goto() {
await this.page.goto('https://example.com/login');
}
async login(username, password) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}
module.exports = { LoginPage };This class groups every login-related locator and action together in one dedicated file, completely separate from the actual test logic.
Using the Page Object Inside a Test
const { test, expect } = require('@playwright/test');
const { LoginPage } = require('./pages/LoginPage');
test('user logs in with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('sarah_connor', 'securepass123');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('user sees error with invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('sarah_connor', 'wrongpassword');
await expect(loginPage.errorMessage).toBeVisible();
});Both tests read almost like plain instructions, since every locator detail lives safely inside the LoginPage class instead of cluttering the test itself.
Building a Second Page Object
class DashboardPage {
constructor(page) {
this.page = page;
this.welcomeHeading = page.getByRole('heading', { name: /Welcome/ });
this.logoutButton = page.getByRole('button', { name: 'Log Out' });
}
async logout() {
await this.logoutButton.click();
}
}
module.exports = { DashboardPage };Each page on a website typically earns its own class, following the exact same pattern shown in the LoginPage example.
Combining Multiple Page Objects in One Test
test('user logs in and then logs out', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('sarah_connor', 'securepass123');
await expect(dashboardPage.welcomeHeading).toBeVisible();
await dashboardPage.logout();
});This test moves cleanly across two different pages, using each page object at the appropriate step in the journey.
Quick Practice Task
Pick a page from one of your existing tests and extract its locators into a dedicated page object class. Update the test to use that new class.
Key Takeaways
- Page objects group locators and actions into one reusable class per page.
- Tests call clear, readable methods instead of writing raw locators repeatedly.
- Updating a broken selector requires changing only one class file.
- This pattern scales cleanly as a test suite grows into hundreds of tests.
