A page object model (POM) organizes browser tests by app page. Each page or component gets one class. The class stores element finders, called locators. It also stores user actions. Tests call those actions instead of using selectors directly. When a page changes, you fix one class. You do not fix two hundred tests.
The basic idea is old. Modern Playwright changes how we should build it.
Many page objects still follow Selenium tutorials from 2015. They work, but they ignore useful Playwright features. Here are five signs and the simpler pattern that replaces them.
Sign 1: Every page object extends BasePage
export class LoginPage extends BasePage {
constructor(driver: Driver) { super(driver); }
}
Inheritance means one class receives behavior from a parent class. The 2015 logic made every page share one parent. That parent often becomes a junk drawer. It collects waits, logs, screenshots, and unused helpers.
Inheritance ties every page to the parent class. Modern page objects need less shared code. Shared helpers can live in plain functions. Test setup can live in fixtures, which prepare objects for each test.
Sign 2: Wait methods everywhere
await loginPage.waitForPageToLoad();
await loginPage.waitForSpinnerToDisappear();
Playwright checks elements before many user actions. For click, it checks visibility, stability, events, and enabled state. Many manual wait methods only repeat those checks. They can also hide timing bugs.
Wait explicitly only for conditions Playwright cannot infer. One example is a dashboard finishing a calculation. Use an assertion, which checks an expected result:
await expect(total).toHaveText('41.97');
Sign 3: Locators buried inside methods
async login(user: string, pass: string) {
await this.page.locator('#username').fill(user);
await this.page.locator('#password').fill(pass);
await this.page.locator('button[type=submit]').click();
}
Hidden locators make page dependencies hard to see. Declare each locator once in the constructor:
export class LoginPage {
readonly username: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(readonly page: Page) {
this.username = page.getByLabel('Username');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Sign in' });
}
async login(user: string, pass: string) {
await this.username.fill(user);
await this.password.fill(pass);
await this.submit.click();
}
}
Now every locator is visible at a glance. The code also uses user-facing names through getByRole and getByLabel. Those names often survive redesigns. A CSS selector like #username may not.
Sign 4: Tests build their own page objects
test('login works', async ({ page }) => {
const loginPage = new LoginPage(page); // every test, every file
...
});
This setup repeats in every test. A Playwright fixture prepares the page object once:
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login-page';
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
// login.spec.ts
import { test } from './fixtures';
test('login works', async ({ loginPage }) => {
await loginPage.login('anton', 'secret');
});
The test asks for loginPage and receives a ready object. There is no repeated setup. One fixture can also depend on another fixture.
Sign 5: Page objects that assert
A loginPage.verifyDashboardIsCorrect() method lets the page object define "correct." The expected result now hides inside a shared class.
Modern split: page objects act, tests assert. The page object returns locators or values. The test states the expectation in plain sight:
await loginPage.login('anton', 'secret');
await expect(dashboard.greeting).toHaveText('Welcome, Anton');
When this fails, the expected value is visible in the test. You know what broke.
The migration path (no rewrite required)
You do not need a full rewrite:
- New page objects follow the modern shape from day one.
- When a test touches an old class, upgrade only that class.
- Delete
extends BasePage and move locators into the constructor.
- Delete duplicate wait methods and move assertions into tests.
- Add fixtures early. New tests get clean setup while old tests keep working.
Do AI agents change any of this?
They make this structure more important. Page objects give an AI agent a small list of approved actions. Raw selectors make the agent rediscover each page. That creates fragile tests. Calling loginPage.login() uses the same reviewed action as a human-written test.
Small, flat page objects give machines fewer ways to make mistakes. Large parent classes give them more choices.
The pattern in one line
Declare locators in the constructor, user-facing. Let fixtures do the wiring. Let auto-waiting do the waiting. Keep assertions in tests. Share nothing through inheritance.
Your page objects should be the most boring code you own. That is what makes them last another ten years.
Anton Gulin is the AI QA Architect — the first person to claim this title on LinkedIn. He builds AI-powered test automation systems where AI agents and human engineers collaborate on quality. Former Apple SDET (Apple.com / Apple Card pre-release testing). Find him at anton.qa or on LinkedIn.