Playwright Page Object Model: What Good Looks Like

playwright page object model

Playwright Page Object Model: What Good Looks Like, and Where It Breaks

You've decided to use the Page Object Model. Good, now you go looking for "the right way" to build one, because you don't want to redo this in three months. You open a few highly-recommended examples to copy the structure. Then you actually read them side by side, and they don't agree with each other. One puts locators as class properties, another builds them inline per method. One keeps page objects action-only, another has assertions baked in. You start wondering if you're the one doing it wrong, or if "the right way" was never really a thing.

It isn't. POM is a convention, not a spec. Nobody enforces it. That's the whole article.

If you're still deciding whether POM is worth adopting at all, see the earlier piece on that [link to be added] — this one assumes you've already made that call and want the implementation right.

Why the Examples Everyone Copies Don't Agree With Each Other

Start with the most authoritative source available: Playwright's own documentation page on the Page Object Model. It's the example nearly every tutorial, course, and "how to structure your tests" blog post either links to or lifts wholesale. Here's the getStarted() method from that official example:

async getStarted() {
  await this.getStartedLink.first().click();
  await expect(this.gettingStartedHeader).toBeVisible();
}

Look at the second line. That's an assertion, sitting inside a page object method, not in the test.

This matters because "keep assertions out of page objects" is one of the few POM rules that's actually repeated consistently across the ecosystem, enough that people have written tooling to enforce it. There's a public ESLint plugin built specifically for Playwright POM that ships a rule called allow-assertions-only-in-flows, whose entire job is to fail your build if an expect() shows up anywhere in a page object file. That rule exists because teams kept doing exactly what Playwright's own docs example does.

This isn't a gotcha aimed at the Playwright team. The example is small, deliberately simple, and arguably fine for a docs page whose job is to show you the shape of the pattern, not enforce it. That's the actual point: even the most-copied reference implementation in the entire ecosystem, built on top of an open-source test automation tools that plenty of teams adopt precisely because nothing is hidden, treats "no assertions in page objects" as optional, because there's no compiler, linter, or runtime that enforces it unless you install one. POM isn't a contract. It's a habit your team either maintains through repetition and code review, or doesn't.

So before you copy a structure from a repo with a lot of stars, know what you're actually copying: someone else's interpretation, held together by nothing but the fact that nobody on their team changed it yet.

What Actually Belongs in a Page Object

Locators and actions. That's the list.

Not assertions, you just saw where that habit leads. Not waits or sleeps, Playwright auto-waits on locators already; a manual waitForTimeout() inside a page object is usually a sign something upstream is flaky, not a fix for it. Not test-specific branching, if (isPremiumUser) { ... } belongs in the test, where the reader can see the test's intent, not buried three files away in a method that's supposed to be reusable.

Here's what a clean one looks like:

import { type Locator, type Page } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Log in' });
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

The test that uses it stays readable because it isn't doing any of that work itself:

test('user can log in with valid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'correct-password');

  await expect(page.getByText('Welcome back')).toBeVisible();
});

Notice where the expect() lives, in the test, checking the outcome of the login, not inside login() checking that a field got filled. The page object doesn't know or care what "success" means for this particular test. That's the test's job. A page object that starts asserting is a page object that's quietly deciding what your tests are allowed to check.

One more line worth drawing: a page object method should map to something a user actually does, login(), addToCart(), submitSearch(), not to a raw Playwright call with a new name. If a method is just async clickButton() { await this.button.click(); }, you haven't abstracted anything, you've renamed .click(). That's not a page object, it's a detour. For the broader set of decisions this one belongs to (abstraction layer, auth strategy, isolation), see our Playwright best practices guide.

A File Structure That Still Makes Sense at 30 Pages

A single pages/ folder holding 30 flat files works fine on day one and turns into a scavenger hunt by month six. A structure that still makes sense later needs three things a flat folder doesn't give you: a shared base, a place for things that aren't full pages, and a place for the objects that wire them together.

tests/
├── pages/
│   ├── base-page.ts
│   ├── login-page.ts
│   ├── dashboard-page.ts
│   └── checkout-page.ts
├── components/
│   ├── nav-bar.ts
│   ├── search-modal.ts
│   └── notification-toast.ts
├── fixtures/
│   └── auth-fixture.ts
└── specs/
    ├── login.spec.ts
    └── checkout.spec.ts

base-page.ts holds what every page shares: the constructor pattern, maybe a waitForLoad() helper, common navigation. Every page object extends it. This is where you fix a cross-cutting problem once instead of in 30 files.

components/ is the one folder most teams skip until it's too late. The moment a piece of UI shows up on more than one page, a nav bar, a filter sidebar, a modal that opens from three different flows, it stops being part of any single page object and becomes its own class. dashboard-page.ts and checkout-page.ts both instantiate NavBar, they don't both redefine its locators. The tell that you're overdue for this split: you're pasting the same three locators into a second page object and telling yourself you'll refactor later.

fixtures/ is where setup lives, logging in, seeding state, anything a test needs before it starts, not part of what it's testing. This one earns its own section next, because "fixtures vs. page objects" is a framing that trips people up more than it should.

Thirty pages in, this structure means a UI change to the nav bar is one file, a new page is one new file in pages/, and nobody's opening login-page.ts to find out the nav bar's logout button is duplicated in there too.

Fixtures or Page Objects? Wrong Question

This gets framed as an either/or a lot, usually by someone who just read two blog posts that each picked a side. It isn't either/or. They solve different problems, and the confusion mostly comes from one bad habit: stuffing setup logic into a page object's constructor because that's where the "page" feels like it should get "ready."

Page objects encapsulate how you interact with the UI: locators, actions, the login() and addToCart() methods from the last section. Fixtures handle what needs to exist before the test runs: an authenticated session, a seeded test account, a browser context configured a certain way. One describes interaction. The other describes setup and teardown. Playwright's fixture system exists specifically so tests don't have to construct that setup by hand every time.

The pattern that actually works is a fixture that builds the page object and hands it back already configured:

// fixtures/auth-fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { DashboardPage } from '../pages/dashboard-page';

type AuthFixtures = {
  authenticatedDashboard: DashboardPage;
};

export const test = base.extend<AuthFixtures>({
  authenticatedDashboard: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login(process.env.TEST_USER_EMAIL!, process.env.TEST_USER_PASSWORD!);

    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },
});

The test never touches LoginPage at all, it just asks for a dashboard that's already logged in:

import { test } from '../fixtures/auth-fixture';
import { expect } from '@playwright/test';

test('user can view their active subscriptions', async ({ authenticatedDashboard }) => {
  await authenticatedDashboard.openBillingTab();
  await expect(authenticatedDashboard.subscriptionList).toBeVisible();
});

That's the whole relationship: the fixture owns getting to the state, the page object owns acting on the state, and the test only ever sees the second half.

The common mistake is skipping the fixture and putting the login call inside the page object's constructor instead, new DashboardPage(page) secretly logging someone in behind the scenes. It looks like it saves a line. It costs you two things: every test using that page object now pays for a login it may not need, and a test failure in DashboardPage's constructor shows up as a mysterious dashboard error when the real problem was auth. Setup that isn't visible in the test is setup nobody can debug from the test. Keep it in the fixture, where it's named and where a failure points at the right thing. Auth strategy carries the same "decide it once, live with it for years" weight as everything else in our Playwright best practices piece — this is one more version of that same call.

Where This Breaks Down as the Team Grows

None of the above is hard to write. It's hard to keep.

A POM built by one person, or reviewed carefully by two, holds together because everyone in the room agrees on the unwritten rules: locators live here, assertions live there, this is what a component is. The moment a third or fourth engineer joins and starts contributing tests, that agreement stops being unwritten and starts being nonexistent. Nothing in the codebase enforces "no assertions in page objects." Nothing enforces "shared UI becomes a component." Those were conventions living in people's heads, and new people don't have access to the heads that held them.

The failure mode isn't dramatic. It's small and it compounds. Here's the one that shows up constantly: two developers, working in the same sprint, both need to interact with the same settings panel. Neither knows the other is touching it. One adds settingsGearIcon as a locator inside AccountPage. The other, working on a different flow that also opens settings, doesn't see that locator, or sees it, but it's named oddly, or lives in a file they didn't think to check, and defines settingsButton inside ProfilePage instead. Same element. Two locators, two files, zero communication between them.

It works fine for months. Then the settings icon gets redesigned, a different data-testid, a different accessible name, and one of the two locators gets updated because whoever made the UI change found and fixed the test that broke. The other one doesn't, because nobody knew it existed. Three weeks later, someone's regression suite is red on a page nobody touched, and the fix is "oh, there were two of these."

Multiply that by however many contributors have shipped a page object without reading every existing one first, and you get the actual failure mode of POM at scale: not bad code, redundant code, quietly maintaining the same knowledge twice in two places that don't know about each other. The pattern doesn't fail because it's a bad pattern. It fails because it was never anything more than an agreement, and agreements don't survive team growth on their own.

Page Object Model Best Practices, in Order of Impact

Not a generic checklist, ordered by how much damage skipping each one actually does, worst first.

1. Write down the convention, in the repo, where PRs can point to it.
Not a wiki page nobody opens. A CONTRIBUTING.md or a pages/README.md sitting next to the code it governs: what belongs in a page object, what doesn't, when something becomes a component. This is the single highest-leverage fix, because every other item on this list only works if there's a shared reference to enforce it against. Without this, "no assertions in page objects" is a rule that lives in one senior engineer's memory and dies the day they're on vacation during a review.

2. Centralize locators, one definition per element, always.
If a locator for the same element exists in two files, that's not redundancy, that's two maintenance burdens that will drift apart the first time only one of them gets updated. This is the exact failure from the last section. The fix isn't clever tooling, it's a rule that gets checked in review: before adding a locator, grep for it first.

3. Let fixtures own setup. Keep it out of constructors.
Covered in detail above, but it's high-impact enough to repeat as a rule: if a page object's constructor does anything beyond assigning locators, that's a setup step hiding where a test can't see it. Fixtures are the visible, debuggable place for "what needs to be true before this test runs."

4. Shared UI becomes a component the moment it appears on a second page.
Not the third page. The second. Waiting for a "clear enough" pattern to emerge is how the nav bar ends up defined four different ways. The rule that actually works is boring and mechanical: the second time you're about to paste a locator into a new page object, stop and make it a component instead.

5. A review checklist that checks the convention, not just the code.
Code review usually asks "does this work?" It rarely asks "does this match how we agreed to structure page objects?" Add three lines to your PR template: locators only in page objects, assertions only in tests, setup only in fixtures. It takes ten seconds to check and it's the difference between a convention that holds and one that quietly erodes one PR at a time.

None of these are enforceable by the compiler. That's not a flaw in the list, it's the actual subject of this whole article. A POM is only as consistent as the discipline a team applies to it, on every PR, indefinitely.

When You'd Rather Not Own the Convention at All

Here's the trade you're actually making when you build a POM by hand: the tests are exactly as readable as the person who designed the structure meant them to be, and to anyone else on the team, that readability isn't free. It's earned only after they've spent real time in the codebase, reading enough page objects to absorb the unwritten rules nobody wrote down. That ramp-up is the actual cost of a hand-built POM. Not the code, the shared context it depends on.

That's the gap BugBug's component model is built to close, not by making POM optional, but by making the consistency part of it structural instead of social. When you record a test, repeated UI, a login form, a nav bar, a checkout flow, becomes a reusable component automatically, defined once, referenced everywhere it's used. There's no style guide to write because there's no second way to define the same element. There's no review checklist item for "did someone duplicate a locator," because duplicating one isn't something the recorder lets you casually do. It's the same underlying idea as codeless test automation generally — structure replacing discipline — applied specifically to the POM problem.

It's worth being honest about where that stops applying. BugBug runs on Chromium-based browsers only, no Firefox, no Safari, no mobile. And if your team is already running a mature, code-owned Playwright suite with deep custom fixtures and data-driven scripting, this isn't a replacement for that investment; the value here is real mainly for teams building that structure for the first time, or a product team that wants to define and reuse a component without opening a pull request to do it. If you're weighing that tradeoff directly, our piece on AI browser automation vs test automation covers the deterministic-vs-flexible question this decision sits on top of.

If that's your team, you've got a POM you're building from scratch, or a page object structure held together by two people's memory and you're not sure it survives the next hire, BugBug's free plan lets you record a component once and reuse it across every test that touches it, no constitution required. Worth ten minutes to see if it fits before you write the convention doc.

Happy (automated) testing!

Your next release. Properly tested.

Join 1,200+ QA teams that automated their
regression coverage with BugBug.

Start testing. It's free.
  • Free plan
  • No credit card
  • 14-days trial

Author

Dominik Szahidewicz

Software Quality Evangelist

Dominik Szahidewicz is a Software Quality Evangelist specialising in quality assurance, test automation, and modern software testing practices. He creates practical, research-driven content that helps QA professionals, developers, and product teams improve test coverage, automate repetitive testing, and release more reliable web applications.

Drawing on his experience in technical writing, data analysis, and application consulting, Dominik translates complex testing concepts into clear, actionable guidance. His areas of interest include end-to-end testing, low-code test automation, regression testing, and the use of AI in software quality assurance.

Reviewer

Mariusz Wójcik photo
Mariusz Wójcik

Senior Software Engineer

Senior software engineer at BugBug, where he's spent 6 years helping shape the product. He's a T-shaped developer skilled in frontend with React and TypeScript, browser extensions, backend work, and building AI agents and tooling. His strengths also include UX instincts, a product-minded approach, and process automation.