When the "Submit" button gets renamed to "Sign In", a suite without Page Object Model requires updating every test that clicks it; with POM, you change one line in LoginPage.ts and the rest follow. The pattern has one common misuse: putting expect() assertions inside page object methods, which makes failure messages ambiguous and breaks the separation between how to interact with a page and what a test verifies. This article builds a complete LoginPage and DashboardPage from scratch, then wires them into Playwright fixtures so tests receive page objects directly in their signatures instead of constructing them manually.
What Page Object Model actually solves
POM is a design pattern, not a Playwright feature. The idea: create a class that represents a page (or a section of a page). The class contains the locators and the methods that interact with that page. Tests use the class instead of raw Playwright commands.
Without POM, your tests look like this:
test('user can log in', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});
test('login fails with wrong password', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});With POM:
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();
});
test('login fails with wrong password', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'wrongpassword');
await expect(page.getByText('Invalid credentials')).toBeVisible();
});When the button changes from "Submit" to "Sign In", you fix it in LoginPage.ts. Both tests stay green without touching them.
Building your first Page Object
Create a pages/ folder at the project root. Inside it, LoginPage.ts:
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly loginButton: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.loginButton = page.getByRole('button', { name: 'Login' });
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Submit' });
}
async goto() {
await this.page.goto('https://lab.becomeqa.com');
}
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Two things to notice. First, locators are defined in the constructor as class properties. This is the Playwright-recommended approach. Locators are lazy: they don't search the DOM when defined, only when used. Second, methods represent user actions (goto, login), not individual clicks. Tests call actions, not implementation details.
Add more pages
Add a DashboardPage.ts for the page after login:
import { Page, Locator } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly heading: Locator;
readonly itemsTable: Locator;
readonly addItemButton: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading', { name: 'My Travel Items' });
this.itemsTable = page.getByRole('table');
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
}
async isLoaded() {
await this.heading.waitFor({ state: 'visible' });
}
async getRowCount() {
const rows = this.page.getByRole('row');
return await rows.count() - 1; // subtract header row
}
async clickAddItem() {
await this.addItemButton.click();
}
}Tests now read like a user flow:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('dashboard shows items after login', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await dashboardPage.isLoaded();
const rowCount = await dashboardPage.getRowCount();
expect(rowCount).toBeGreaterThan(0);
});Use a BasePage for shared behavior
If you have 10 page objects and they all need the same navigation logic or a common waitForLoad method, put that in a base class:
// pages/BasePage.ts
import { Page } from '@playwright/test';
export class BasePage {
constructor(protected page: Page) {}
async waitForNetworkIdle() {
await this.page.waitForLoadState('networkidle');
}
async getPageTitle() {
return await this.page.title();
}
async scrollToBottom() {
await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
}
}// pages/LoginPage.ts
import { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
readonly loginButton: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: any) {
super(page);
this.loginButton = page.getByRole('button', { name: 'Login' });
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Submit' });
}
async goto() {
await this.page.goto('https://lab.becomeqa.com');
}
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Don't overdo inheritance. One level (BasePage → specific page) is usually enough. Deep inheritance chains become hard to follow.
Turn page objects into fixtures
Creating new LoginPage(page) in every test is repetitive. Playwright fixtures let you inject page objects automatically:
// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from '@playwright/test';Now import test from your fixture file instead of from Playwright:
// tests/login.spec.ts
import { test, expect } from '../fixtures/pages.fixture';
test('user can log in', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(loginPage.page.getByText('My Travel Items')).toBeVisible();
});
test('dashboard shows items after login', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await dashboardPage.isLoaded();
const rowCount = await dashboardPage.getRowCount();
expect(rowCount).toBeGreaterThan(0);
});Page objects appear in the test signature. No constructor calls, no imports of page classes in test files, just the objects, ready to use.
What belongs in a page object and what doesn't
Put in page objects:- Locators for the page's elements
- Methods that represent user actions on the page
- Wait conditions specific to the page (
isLoaded,waitForModal) - Simple data getters (
getRowCount,getHeadingText)
- Assertions (
expect(...)) - Test data
- Test logic (what to do, in what order)
- Descriptions of what the test verifies
The rule: page objects describe HOW to interact with the page. Tests describe WHAT to verify. If you put assertions inside page objects, tests become harder to read and failures become harder to diagnose.
// Wrong — assertion inside page object
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
await expect(this.page.getByText('My Travel Items')).toBeVisible(); // don't do this
}
// Right — page object just performs the action
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
// Test does the assertion
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();Project structure with POM
A clean folder structure for a POM-based project:
project/
pages/
BasePage.ts
LoginPage.ts
DashboardPage.ts
ItemsPage.ts
fixtures/
pages.fixture.ts
auth.fixture.ts
tests/
auth/
login.spec.ts
logout.spec.ts
items/
items-list.spec.ts
items-crud.spec.ts
api/
items-api.spec.ts
playwright.config.ts
package.jsonFAQ
Should every page in the app have a page object?Only pages that have automated tests. If you have a settings page with no tests, don't create a SettingsPage.ts just to have it.
Split by section. A page with a table, a modal, and a sidebar can become ItemsTablePage, AddItemModal, and a parent ItemsPage that composes them. Or extract the modal into its own class and import it into the page.
The pattern is sometimes called "API Objects": a class that wraps related API calls the same way a page object wraps UI interactions. It's useful when many tests hit the same endpoints. Not mandatory, but the same benefits apply.
When should I switch from no-POM to POM?When you find yourself copy-pasting the same 4-5 lines for the third time. That's the signal. Don't build page objects speculatively before you know you need them.