Nathen Watters
testing13 min read

Building a Page Object Model That Doesn't Become a Maintenance Nightmare

Page Objects are supposed to make test suites easier to maintain. Here's why they often make things worse — and the patterns that actually hold up.

·
testinge2eplaywrightpage-object-modeltest-designautomation

The Page Object Model gets recommended so often it's practically a default. And for good reason — centralizing your selectors and interactions reduces duplication and makes tests easier to read. But talk to engineers who've maintained a large test suite for a few years and you'll hear a different story: Page Objects that became thousands-of-line god classes, inheritance hierarchies nobody understands, and abstractions that made the tests harder to change than the code they were testing.

The pattern isn't wrong. The implementation usually is. Here's what goes sideways and how to avoid it.

What Page Objects Are Actually For

Before getting into the pitfalls, it's worth being precise about what Page Objects are supposed to do.

They're not a framework. They're not a way to "hide Playwright." They're a centralization strategy: put your selectors and user interactions in one place so that when the UI changes, you change the Page Object and not thirty test files.

That's the whole job. If your Page Objects are doing more than that, they're probably doing too much.

The God Class Problem

The most common failure mode: one Page Object per page, everything on that page goes in it.

// 600 lines later...
export class DashboardPage {
  async clickCreateButton() { ... }
  async fillProjectName(name: string) { ... }
  async selectTeamMember(name: string) { ... }
  async openSettingsDropdown() { ... }
  async toggleNotifications() { ... }
  async filterByStatus(status: string) { ... }
  async exportToCSV() { ... }
  async archiveProject(name: string) { ... }
  // ...40 more methods
}

This class is covering five different features. Any change to the dashboard UI means opening this file, finding the right method among dozens, and hoping you don't break something unrelated.

The fix is to model your Page Objects around features, not pages.

export class ProjectCreationFlow {
  constructor(private page: Page) {}
 
  get nameInput() { return this.page.getByLabel('Project name') }
  get teamMemberSelect() { return this.page.getByLabel('Add team member') }
  get createButton() { return this.page.getByRole('button', { name: 'Create project' }) }
 
  async create(name: string, teamMember?: string) {
    await this.nameInput.fill(name)
    if (teamMember) await this.teamMemberSelect.selectOption(teamMember)
    await this.createButton.click()
  }
}
 
export class ProjectSettingsPanel {
  constructor(private page: Page) {}
  // ...
}

Smaller, focused classes. Easier to find, easier to change.

Inheritance That Collapses Under Its Own Weight

Another common pattern: a BasePage class with shared methods, everything else extends it.

class BasePage {
  constructor(protected page: Page) {}
  async waitForLoad() { ... }
  async getToastMessage() { ... }
  async closeModal() { ... }
  async scrollToBottom() { ... }
}
 
class LoginPage extends BasePage { ... }
class DashboardPage extends BasePage { ... }
class SettingsPage extends BasePage { ... }

This looks reasonable until BasePage starts accumulating everything that's shared across more than one page. Then you're editing a base class that affects 20 subclasses, and a change to getToastMessage() breaks tests for pages that have nothing to do with the change you made.

Prefer composition over inheritance. Extract shared behaviors into standalone utilities:

// Shared utilities — not a base class
export async function dismissToast(page: Page) {
  await page.getByRole('status').waitFor()
  return page.getByRole('status').textContent()
}
 
export async function closeModal(page: Page) {
  await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click()
}

These are just functions. They're easy to test, easy to find, and a change to one doesn't cascade across your entire Page Object hierarchy.

When BasePage Actually Makes Sense

The "avoid inheritance" advice is a reaction to a specific failure mode — the catch-all base class that becomes a junk drawer. But inheritance isn't inherently wrong. There are cases where a BasePage is genuinely the right tool.

When every page shares real structural behavior. If your app has authenticated routes that all require a nav bar, a consistent header, and a shared loading state, and those are things your tests legitimately interact with — a base class is a reasonable home for them:

class AuthenticatedPage {
  constructor(protected page: Page) {}
 
  get navBar() { return this.page.getByRole('navigation') }
  get userMenu() { return this.page.getByRole('button', { name: 'Account menu' }) }
 
  async waitForPageReady() {
    // networkidle can be unreliable in SPAs with polling or websockets — prefer a specific element assertion
    await this.page.waitForLoadState('domcontentloaded')
    await expect(this.navBar).toBeVisible()
  }
 
  async signOut() {
    await this.userMenu.click()
    await this.page.getByRole('menuitem', { name: 'Sign out' }).click()
  }
}
 
class DashboardPage extends AuthenticatedPage {
  get projectList() { return this.page.getByRole('list', { name: 'Projects' }) }
  // ...
}

This works because AuthenticatedPage describes something real and bounded — the authenticated shell — not "stuff that happens to be shared between more than two pages."

When you're modeling a component hierarchy, not a page hierarchy. If your app has deeply nested layouts (a shell, a section, a sub-page), inheritance can mirror that structure cleanly. The key is that each level of the hierarchy should describe a genuine layer of the UI, not just group shared methods.

The rule of thumb: a base class earns its place when you can give it a name that describes what it is, not just what it contains. AuthenticatedPage, ModalPage, DataTablePage — these describe a real concept. BasePage usually doesn't.

Tip

If your base class is named BasePage or PageBase, that's often a sign it's organized around shared implementation rather than shared concept. Consider whether renaming it forces you to tighten its scope.

Hiding Too Much

There's a temptation to make Page Objects so abstract that tests read like plain English:

test('project creation flow', async ({ page }) => {
  const dashboard = new DashboardPage(page)
  await dashboard.createProject('My Project', 'alice@example.com')
  await dashboard.verifyProjectExists('My Project')
})

The test is readable, but you've hidden all the meaningful detail. When it fails, you have no idea what interaction failed or why. You're debugging the Page Object, not the product.

Leave enough detail in the test that a failure is self-explanatory:

test('new project appears in dashboard after creation', async ({ page }) => {
  const flow = new ProjectCreationFlow(page)
  await page.goto('/dashboard')
  await flow.create('My Project')
 
  await expect(page.getByRole('heading', { name: 'My Project' })).toBeVisible()
})

The assertion is in the test, not buried in a verifyProjectExists helper. When it fails, you know exactly what wasn't visible.

The multi-step journey problem

The verifyProjectExists example is a single-step case. The failure mode gets worse as flows get longer. Consider a 20-step user journey — onboarding, a checkout, a multi-page wizard — where the final assertion is "user sees confirmation screen." If step 8 fails silently (a button doesn't navigate, a modal doesn't close, the form resets unexpectedly), the test doesn't fail there. It keeps running, the subsequent steps fail in confusing ways, and eventually the final assertion fails with an error that has nothing to do with what actually went wrong.

// The whole journey is one black box — step 8 fails, but you see a step 20 error
test('user completes onboarding', async ({ page }) => {
  const onboarding = new OnboardingFlow(page)
  await onboarding.completeStep1()
  await onboarding.completeStep2()
  // ...18 more steps
  await onboarding.verifyOnboardingComplete() // fails here, but the real problem was step 8
})

The fix is to assert on the outcome of each meaningful step inside the test, not just the final destination. A failing step should fail the test at that step, with an error message that reflects what actually broke.

test('user completes onboarding', async ({ page }) => {
  const flow = new OnboardingFlow(page)
 
  await page.goto('/onboarding')
  await flow.completeProfileStep({ name: 'Nathen', role: 'QE Engineer' })
  // Assert we reached step 2 before proceeding — if this fails, we know step 1 didn't navigate
  await expect(page.getByRole('heading', { name: 'Connect your tools' })).toBeVisible()
 
  await flow.completeIntegrationsStep({ tool: 'GitHub' })
  // Assert we reached step 3
  await expect(page.getByRole('heading', { name: 'Invite your team' })).toBeVisible()
 
  await flow.completeTeamStep()
  await expect(page.getByRole('heading', { name: "You're all set" })).toBeVisible()
})

Now if step 1 fails to navigate, the test fails on the first expect — not 18 steps later. The error message tells you exactly which transition broke.

Tip

A useful heuristic: assert on navigation and state transitions between steps, not just at the end. The question isn't "did we reach the final screen?" — it's "did each step land where we expected before we took the next one?"

This does make the test longer. It also raises a follow-on question: when multiple tests share the same first 14 steps, how do you avoid duplicating them everywhere? Fixtures, test.step, and flow facades are all tools for this — covered in Organizing Reusable Flows in Playwright Without Making a Mess.

The added length is intentional. A long multi-step test should read like a detailed script — the length is documenting the journey, and the intermediate assertions are what make failures debuggable. The alternative (one assertion at the end) is shorter to write and much harder to debug.

Lazy Locators Over Eager Initialization

A subtle but common issue: initializing locators in the constructor.

export class LoginPage {
  private emailInput: Locator
  private passwordInput: Locator
 
  constructor(private page: Page) {
    // These are evaluated immediately
    this.emailInput = page.locator('#email')
    this.passwordInput = page.locator('#password')
  }
}

Playwright locators are lazy by design — they don't query the DOM until you interact with them, regardless of where they're initialized. The behavioral difference between constructor initialization and getters is minimal, but getters make the intent clearer: each property access is a fresh query expression, not a stored reference. This matters stylistically as the class grows and makes it easier to reason about what each locator refers to at the point of use.

export class LoginPage {
  constructor(private page: Page) {}
 
  get emailInput() { return this.page.getByLabel('Email') }
  get passwordInput() { return this.page.getByLabel('Password') }
  get submitButton() { return this.page.getByRole('button', { name: 'Log in' }) }
 
  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }
}

Getters make it clear that each access is a fresh locator query. It also plays nicely with Playwright's auto-waiting — the locator is resolved at interaction time, not at construction time.

Keeping Methods at One Level of Abstraction

Page Object methods fall into two categories: low-level interactions (fill an input, click a button) and high-level flows (complete a form, submit an order). Mixing them in the same class makes it hard to know which layer you're working at.

A good rule: if a method calls other methods on the same object, it's probably a flow method and should be clearly named as such.

export class CheckoutPage {
  constructor(private page: Page) {}
 
  // Low-level — individual interactions
  get cardNumberInput() { return this.page.getByLabel('Card number') }
  get expiryInput() { return this.page.getByLabel('Expiry') }
  get cvcInput() { return this.page.getByLabel('CVC') }
  get submitButton() { return this.page.getByRole('button', { name: 'Pay now' }) }
 
  // High-level — named flow that composes the above
  async completePayment(card: { number: string; expiry: string; cvc: string }) {
    await this.cardNumberInput.fill(card.number)
    await this.expiryInput.fill(card.expiry)
    await this.cvcInput.fill(card.cvc)
    await this.submitButton.click()
  }
}

Tests that need fine-grained control use the locators directly. Tests that just need to get past the payment step call completePayment. Both options are available without creating two separate classes.

Modeling Reusable UI Components

Most apps have UI components that appear across many pages — a confirmation modal, a data table with sorting and pagination, a date picker, a command palette. These aren't tied to a single page, so they don't belong in any one Page Object. But duplicating their selectors everywhere defeats the whole point of the pattern.

The answer is a Component Object — a class that encapsulates a specific UI component and accepts a locator as its root, rather than a Page.

// components/ConfirmationModal.ts
import { type Locator } from '@playwright/test'
 
export class ConfirmationModal {
  constructor(private root: Locator) {}
 
  get heading() { return this.root.getByRole('heading') }
  get confirmButton() { return this.root.getByRole('button', { name: 'Confirm' }) }
  get cancelButton() { return this.root.getByRole('button', { name: 'Cancel' }) }
 
  async confirm() {
    await this.confirmButton.click()
  }
 
  async cancel() {
    await this.cancelButton.click()
  }
}

The key difference: it takes a Locator instead of a Page. This scopes all queries inside it to that root element, which means it works correctly even when the same component appears multiple times on a page.

Any Page Object that needs it composes it in:

// pages/ProjectsPage.ts
import { type Page } from '@playwright/test'
import { ConfirmationModal } from '../components/ConfirmationModal'
 
export class ProjectsPage {
  constructor(private page: Page) {}
 
  get deleteModal() {
    return new ConfirmationModal(
      this.page.getByRole('dialog', { name: 'Delete project' })
    )
  }
 
  async deleteProject(name: string) {
    await this.page.getByRole('row', { name }).getByRole('button', { name: 'Delete' }).click()
  }
}
// In a test
const projects = new ProjectsPage(page)
await projects.deleteProject('My Project')
await projects.deleteModal.confirm()
await expect(page.getByRole('row', { name: 'My Project' })).not.toBeVisible()

The same ConfirmationModal component can be composed into SettingsPage, TeamPage, or any other Page Object that triggers a confirmation dialog — with a different root locator each time. If the modal's button labels or structure change, you fix it in one file.

This pattern extends naturally to more complex components. A reusable data table:

// components/DataTable.ts
export class DataTable {
  constructor(private root: Locator) {}
 
  row(name: string) { return this.root.getByRole('row', { name }) }
  get pagination() { return this.root.getByRole('navigation', { name: 'Pagination' }) }
 
  async nextPage() {
    await this.pagination.getByRole('button', { name: 'Next' }).click()
  }
 
  async sortBy(column: string) {
    await this.root.getByRole('columnheader', { name: column }).click()
  }
}

Used in any page that renders that table — each with its own root locator, zero duplication of table interaction logic.

Tip

The boundary between a Component Object and a Page Object is the constructor parameter: Component Objects take a Locator (scoped to a fragment), Page Objects take a Page (scoped to the full document). If you're not sure which one to write, ask whether the thing you're modeling could appear more than once on a page. If yes, it's a Component Object.

A Structure That Scales

A directory layout that keeps things findable as the suite grows:

tests/
  pages/           # Page Objects (take Page)
    LoginPage.ts
    CheckoutPage.ts
    ProjectsPage.ts
  components/      # Component Objects (take Locator)
    ConfirmationModal.ts
    DataTable.ts
    DatePicker.ts
  fixtures/        # Playwright fixtures and shared setup
    auth.ts
  utils/           # Stateless helper functions
    toast.ts
  specs/           # Tests
    auth.spec.ts
    projects.spec.ts

Page Objects own full-page interactions. Component Objects own scoped UI fragment interactions. Fixtures own setup and teardown. Utils are stateless helpers. Nothing bleeds across the boundaries.

The Maintenance Test

If you're not sure whether your Page Objects are in good shape, ask: how long does it take to update the suite after a UI change?

If the answer is "I update one file and the tests pass," the abstraction is working. If the answer is "I search for the selector across six files and hope I got them all," it isn't.

Page Objects aren't about making tests look clean. They're about making changes cheap. Every design decision should be evaluated against that goal.