A developer renames one button. Thirty tests go red. Nothing is broken in the product, and you spend the afternoon editing the same selector in thirty files.
That is the problem the Page Object Model solves. It is not about tidiness. It is about making one change to the app cost one change to your tests.
What a page object actually is
A page object is a small class that holds the selectors for one screen, and the actions you can do on it. Your test asks the page object to do things. It never touches selectors itself.
Here is a test without one:
await page.goto('/login');
await page.locator('#email').fill('demo@nimbus.app');
await page.locator('#password').fill('secret123');
await page.locator('button[type="submit"]').click();
await expect(page.locator('.welcome-banner')).toBeVisible();
And the same test with one:
const login = new LoginPage(page);
await login.open();
await login.signIn('demo@nimbus.app', 'secret123');
await expect(dashboard.welcomeBanner).toBeVisible();
The second version is not shorter for its own sake. It is shorter because it says what happens, not how. When the login form is redesigned, you edit LoginPage once. Every test that signs in keeps working, untouched.
The class itself is ordinary code:
export class LoginPage {
constructor(private page: Page) {}
private email = () => this.page.getByLabel('Email');
private password = () => this.page.getByLabel('Password');
private submit = () => this.page.getByRole('button', { name: 'Sign in' });
async open() {
await this.page.goto('/login');
}
async signIn(email: string, password: string) {
await this.email().fill(email);
await this.password().fill(password);
await this.submit().click();
}
}
The three rules that decide whether it helps
Most bad page objects break one of these. They are worth learning as rules, because each one prevents a specific kind of pain.
Rule 1: name things after intent, not after the screen
contacts.delete('Dana Fox'); // good: what the user achieves
contacts.clickDeleteButton(); // weak: how the screen works today
The second name describes the current design. When the delete button becomes a menu item, the method name is a lie, and you have to rename it everywhere. Names built from intent stay true through a redesign.
Rule 2: page objects act, tests judge
This is the rule teams get wrong most often, so it is worth being precise about.
// wrong: the page object decides what success means
await contacts.deleteAndVerify('Dana Fox');
// right: the page acts, the test decides
await contacts.delete('Dana Fox');
await expect(contacts.row('Dana Fox')).toBeHidden();
The first version can only ever tell one story. You cannot reuse it in a test about deletion being refused, or about a user without permission, because the assertion is welded inside the action.
A page object should expose state so the test can assert on it. It should not contain the assertions itself.
Rule 3: model components, not just whole pages
The same data table appears on twelve screens. If you model screens only, you write that table twelve times, and a change to it means twelve edits.
class ContactsPage {
constructor(private page: Page) {
this.grid = new DataGrid(page, page.getByTestId('contact-table'));
this.filters = new FilterBar(page, page.getByTestId('filters'));
}
}
A component is just a page object scoped to part of the screen. Everything it looks for, it looks for inside its own root, so two tables on one page cannot get confused with each other. One DataGrid class then serves every screen that has a grid.
This is the single change that makes a large suite survive a redesign.
Where it goes wrong
Abstraction has a cost, and it is real. Watch for these.
Too many layers. A test that reads beautifully but needs four files open to understand is not better than a plain one. The check worth applying: can a new engineer read the test and correctly predict what the screen does? If they have to go digging, the abstraction has gone too far.
Deep inheritance. BasePage to AuthenticatedPage to AdminPage to AdminContactsPage works until a screen needs behaviour from two branches, and then you are stuck. Use inheritance for what a page is (every page can load and navigate). Use composition for what a page has (this page has a grid, a filter bar and a dialog).
Page objects that wait badly. If every method has its own timing logic written from memory, you have spread flaky behaviour across the whole suite. Put waiting in one shared place and call it from the page objects.
Building it too early. For five tests, page objects are overhead. The honest rule is the same as for any helper: the second time you write the same selector, move it. Not the first time, not the fifth.
Does it still matter in Playwright and Cypress?
Partly, and it is worth being honest about this rather than repeating advice from 2015.
Modern locators already solve some of what page objects were invented for. getByRole('button', { name: 'Sign in' }) is readable and survives a restyle on its own, so wrapping it adds less than wrapping a brittle CSS path used to.
What page objects still buy you in every tool:
- Shared flows. Signing in, creating a record, completing checkout. Writing these once is valuable no matter how good your locators are.
- One place to change. A redesign still touches one file instead of thirty.
- A vocabulary. Tests that read in the language of the product, not the language of the DOM.
In Cypress, many teams use custom commands for the same purpose. That is fine. The pattern matters more than the container it lives in.
In Selenium the pattern matters most of all, because Selenium gives you a library and nothing else. There is no built-in structure, so whatever structure exists is the one you built.
Remember this
Model what the app is made of. Name methods after what the user achieves. Keep assertions in the tests, never inside the page object. Then a redesign costs you one file instead of an afternoon.
Go deeper in the course, in your own framework: Cypress, Playwright or Selenium.
Reference: Selenium's page object guidance.