Resource library

QA How-To

How to Write maintainable page objects (2026)

Learn how to write maintainable page objects with thin screens, stable locators, flows, fixtures, anti-patterns, and Playwright, Cypress, and Selenium examples.

22 min read | 2,347 words

TL;DR

Write thin page objects around screens or components, stable locators, and user-intent methods. Orchestrate multi-page paths with flows, assert outcomes in tests, and prefer composition over inheritance so UI change stays localized.

Key Takeaways

  • Keep page objects thin, screen-scoped, and intention-revealing.
  • Prefer role, label, and test id locators over brittle CSS and XPath.
  • Use flows for multi-page journeys and composition for shared fragments.
  • Put most business assertions in tests, not buried inside page methods.
  • Avoid god classes, deep inheritance, sleeps, and global mutable drivers.
  • Promote abstractions on reuse (three-strike rule) instead of premature frameworks.
  • Enforce standards with review checklists and developer testability contracts.

Learning how to write maintainable page objects means building UI test abstractions that absorb change instead of amplifying it. Page objects should hide selectors and low-level interactions, expose intention-revealing actions, and stay thin enough that engineers still understand the underlying Playwright, Cypress, or Selenium APIs. When page objects become a second framework, maintenance cost explodes.

This guide is for SDETs and QA automation engineers working in 2026 codebases. You will get design rules, anti-patterns, comparison tables, runnable TypeScript and Java examples, and interview answers you can defend in a senior loop.

TL;DR

Keep page objects focused on a single screen or meaningful fragment. Use stable locators (roles, labels, test ids). Put multi-step journeys in flow helpers. Keep most assertions in tests. Avoid deep inheritance and god classes. Prefer composition and fixtures over static global state.

Approach Maintainability Risk Best use
Thin page object High Slight duplication of trivial clicks Most teams
Flow modules High Flows can grow large if unbounded Multi-page journeys
Screenplay-style tasks Medium-High Learning curve, over-abstraction Large multi-team suites
God page object Low Everything breaks at once Avoid
No abstraction Low at scale Selector churn everywhere Only tiny suites

1. How to Write maintainable page objects: What They Are (and Are Not)

A page object is a class or module that models a UI surface: locators plus meaningful interactions. It is not:

  • A dumping ground for every assertion in the product
  • A replacement for the test runner
  • A place to store global mutable session state for all tests
  • An inheritance tree that mirrors your org chart

When teams ask how to write maintainable page objects, the real question is usually: "How do we stop selector changes from editing 40 tests?" The answer is encapsulation with restraint.

Related: how to structure a test automation repository and build a playwright typescript framework from scratch.

2. How to Write maintainable page objects: Design Principles

  1. One primary responsibility per page object (login screen, cart page, invoice detail).
  2. Public methods speak user intent (applyCoupon, submitOrder), not DOM trivia (clickDiv3).
  3. Locators are private or readonly fields, not strings re-exported everywhere.
  4. Waits live in the driver/auto-wait layer when possible, not random sleeps inside pages.
  5. Assertions prefer tests, except ubiquitous readiness checks (expectLoaded).
  6. No work in constructors beyond assigning locators.
  7. Composition over inheritance for shared headers/nav.
  8. Deterministic inputs and outputs (return values when useful, avoid hidden globals).

Write these principles in your contribution guide and enforce in review.

3. Locator Strategy for Stability

Prefer, in order, for Playwright:

  1. getByRole
  2. getByLabel / getByPlaceholder
  3. getByTestId
  4. getByText for unique, stable copy
  5. CSS/XPath only when necessary

Runnable example: maintainable Playwright page object

// src/ui/pages/LoginPage.ts
import type { Page, Locator } from "@playwright/test";
import { expect } from "@playwright/test";

export class LoginPage {
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly formError: Locator;

  constructor(private readonly page: Page) {
    this.email = page.getByLabel("Email");
    this.password = page.getByLabel("Password");
    this.submit = page.getByRole("button", { name: "Sign in" });
    this.formError = page.getByRole("alert");
  }

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

  async expectLoaded() {
    await expect(this.submit).toBeVisible();
  }

  async loginAs(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }
}
// tests/smoke/login.smoke.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../../src/ui/pages/LoginPage";

test("invalid password shows alert", async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.expectLoaded();
  await login.loginAs("user@example.test", "wrong-password");
  await expect(login.formError).toContainText("Invalid email or password");
});

Notice the assertion about business outcome stays in the test. The page object knows how to operate the form.

4. Fragments and Composition Instead of Inheritance

Headers, toasts, and nav bars appear on many screens. Model them as components composed into pages.

// src/ui/components/Toast.ts
import type { Page, Locator } from "@playwright/test";

export class Toast {
  readonly region: Locator;
  constructor(page: Page) {
    this.region = page.getByRole("status");
  }
  message() {
    return this.region;
  }
}
// src/ui/pages/CartPage.ts
import type { Page, Locator } from "@playwright/test";
import { Toast } from "../components/Toast";

export class CartPage {
  readonly toast: Toast;
  readonly couponInput: Locator;
  readonly applyCoupon: Locator;
  readonly total: Locator;

  constructor(private readonly page: Page) {
    this.toast = new Toast(page);
    this.couponInput = page.getByLabel("Coupon code");
    this.applyCoupon = page.getByRole("button", { name: "Apply coupon" });
    this.total = page.getByTestId("cart-total");
  }

  async goto() {
    await this.page.goto("/cart");
  }

  async applyCouponCode(code: string) {
    await this.couponInput.fill(code);
    await this.applyCoupon.click();
  }
}

Avoid BasePage with 40 helper methods "just in case." Shared navigation can be a Nav component. Shared driver utilities can be plain functions.

5. Flows for Multi-Page Journeys

If a journey spans multiple pages, do not force it into one page class. Create a flow module.

// src/ui/flows/checkoutFlow.ts
import type { Page } from "@playwright/test";
import { CartPage } from "../pages/CartPage";
import { PaymentPage } from "../pages/PaymentPage";

// CartPage includes checkoutButton locator
export async function checkoutWithCoupon(page: Page, code: string) {
  const cart = new CartPage(page);
  await cart.goto();
  await cart.applyCouponCode(code);
  await cart.checkoutButton.click();
  const payment = new PaymentPage(page);
  await payment.expectLoaded();
  return payment;
}

Add checkoutButton on CartPage as page.getByRole("button", { name: "Checkout" }). Keep payment concerns on PaymentPage, not as methods smuggled into cart.

Flows keep page objects screen-sized. That is central to how to write maintainable page objects over years.

6. Assertions: What Belongs Where

Assertion type Put in page object? Put in test?
Element readiness for interaction Sometimes (expectLoaded) Optional
Business outcome (total is $90) Rarely Yes
Cross-page outcome No Yes or in flow carefully
Visual snapshot Prefer test or dedicated helper Yes

Too many assertions inside page methods create opaque tests:

// hard to debug: what failed?
await cart.applyCouponAndExpectTotal("SAVE10", "$90.00");

Prefer:

await cart.applyCouponCode("SAVE10");
await expect(cart.total).toHaveText("$90.00");

You can still offer optional convenience methods for extremely common checks, but keep them obviously named and few.

7. Selenium Java Page Objects Without Boilerplate Traps

Classic PageFactory can be fine if kept thin.

// LoginPage.java
package com.example.qa.pages;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;

public class LoginPage {
  private final WebDriver driver;
  private final WebDriverWait wait;

  @FindBy(css = "[data-testid='email']")
  private WebElement email;

  @FindBy(css = "[data-testid='password']")
  private WebElement password;

  @FindBy(css = "[data-testid='sign-in']")
  private WebElement submit;

  public LoginPage(WebDriver driver) {
    this.driver = driver;
    this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    PageFactory.initElements(driver, this);
  }

  public LoginPage open() {
    driver.get("https://staging.example.test/login");
    wait.until(ExpectedConditions.visibilityOf(submit));
    return this;
  }

  public void loginAs(String userEmail, String userPassword) {
    email.clear();
    email.sendKeys(userEmail);
    password.clear();
    password.sendKeys(userPassword);
    submit.click();
  }
}

Fluent returns (return this) are optional sugar. Do not build a DSL that only two people can extend.

8. Cypress: Page Objects vs Custom Commands

Cypress encourages custom commands, but unbounded commands become a global soup. A module pattern often maintains better.

// cypress/support/pages/loginPage.js
export const loginPage = {
  email: () => cy.get('[data-cy="email"]'),
  password: () => cy.get('[data-cy="password"]'),
  submit: () => cy.get('[data-cy="sign-in"]'),
  open() {
    cy.visit("/login");
    this.submit().should("be.visible");
  },
  loginAs(email, password) {
    this.email().clear().type(email);
    this.password().clear().type(password);
    this.submit().click();
  },
};
// cypress/e2e/login.cy.js
import { loginPage } from "../support/pages/loginPage";

describe("login", () => {
  it("shows an error for invalid credentials", () => {
    loginPage.open();
    loginPage.loginAs("user@example.test", "wrong-password");
    cy.get('[role="alert"]').should("contain", "Invalid email or password");
  });
});

Use custom commands for truly cross-cutting concerns (login session, API seed), not for every click.

9. Async, Navigation, and Returning New Pages

When an action navigates, either:

  • Return a new page object from the method, or
  • Let the test construct the next page explicitly

Both can be maintainable. Pick one team convention.

async submitLogin(): Promise<DashboardPage> {
  await this.submit.click();
  const dashboard = new DashboardPage(this.page);
  await dashboard.expectLoaded();
  return dashboard;
}

Beware hidden navigation timeouts buried three methods deep. Keep navigation obvious in the method name (submitLogin, goToCheckout).

10. Test IDs and Contract With Developers

Maintainable page objects depend on a product contract for testability:

  • Stable data-testid / data-cy on critical controls
  • Accessible names for interactive elements
  • Avoid purely visual-only click targets without roles

Document the contract. Offer PR review checklists for UI engineers. Page objects cannot paper over inaccessible, anonymous DOM forever. See also add reporting to a test framework for how failure evidence interacts with locators.

11. Parameterization and Tables Without Page Bloat

Do not create LoginPageForAdmin, LoginPageForUser, LoginPageForEU subclasses for data differences. Pass data in:

await login.loginAs(user.email, user.password);

Use factories for users, not parallel page hierarchies. Page structure should follow UI structure, not persona matrices.

12. Handling Dynamic Lists and Tables

Lists are where brittle indexing thrives.

export class UsersTable {
  constructor(private readonly page: Page) {}

  rowByEmail(email: string) {
    return this.page.getByRole("row", { name: new RegExp(email) });
  }

  async clickEditFor(email: string) {
    await this.rowByEmail(email).getByRole("button", { name: "Edit" }).click();
  }
}

Prefer row queries by accessible name or test id over nth(3). Index-based methods need scary names if truly required (clickEditOnRowIndex for layout tests only).

13. Anti-Patterns to Delete on Sight

  • God page: 1,000 lines modeling half the app
  • Deep inheritance: Base -> LoggedIn -> Admin -> SuperAdminPage
  • Static WebDriver stores with hidden thread issues
  • Sleeps inside page methods to "make it stable"
  • Catch-and-ignore around clicks
  • Duplicated locators as public string constants used by tests and pages differently
  • Business rules implemented in page objects (pricing math, permission logic)
  • Screenshots in every method unconditionally

Each anti-pattern makes how to write maintainable page objects harder for the next engineer.

14. Refactor Playbook for Legacy Page Objects

  1. Identify the hottest file (most churn or most failures).
  2. Extract components for header/toast.
  3. Split god classes by URL or primary heading.
  4. Move assertions back into tests gradually.
  5. Replace sleeps with explicit expects at call sites.
  6. Add a sample test that demonstrates the new style.
  7. Ban new code in the old style via review.

Do not rewrite everything before shipping value. Strangle the worst pages first.

15. Fixtures Wiring for Playwright

// src/fixtures/index.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "../ui/pages/LoginPage";
import { CartPage } from "../ui/pages/CartPage";

type Pages = {
  loginPage: LoginPage;
  cartPage: CartPage;
};

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  cartPage: async ({ page }, use) => {
    await use(new CartPage(page));
  },
});

export { expect } from "@playwright/test";

Fixtures reduce constructor noise and keep page lifecycle aligned with tests. Avoid singleton pages across parallel workers.

16. Performance and Readability Tradeoffs

Page objects add indirection. That is fine when reuse is real. For a one-off admin screen touched by a single test, inline locators in the spec can be clearer. Promote to a page object on the second or third reuse, or when selectors are volatile and shared.

A healthy rule: three-strike promotion. First use in-test, second use extract if identical, third use must be a page or component module.

Interview Questions and Answers

Q: How do you write maintainable page objects?

I keep them thin and screen-scoped, with stable locators and intention-revealing methods. I place multi-page journeys in flows, keep most assertions in tests, and prefer composition for shared UI fragments. I avoid deep inheritance, global driver state, and sleeps.

Q: Should page objects contain assertions?

Light readiness checks are fine. Business outcome assertions usually belong in tests so failures read clearly and pages do not become opaque one-liners that hide what was verified.

Q: Page object model vs screenplay pattern?

Page objects are simpler and enough for many teams. Screenplay can help very large suites with reusable tasks and actors, but it adds concepts and can be overkill. I choose based on team size and pain, not trendiness.

Q: How do you manage locators that change often?

Partner with developers on roles and test ids, centralize locators in page modules, and add review rules for UI changes touching critical hooks. If copy is volatile, do not key locators on full marketing strings.

Q: How do you avoid god classes?

Split by screen or component, extract flows for journeys, and review file size and responsibility in PRs. If a page object knows checkout, profile, and admin settings, it is already too big.

Q: What is the biggest page object anti-pattern you have seen?

A base page with dozens of unrelated helpers plus static WebDriver access, combined with assertions buried inside methods. It creates flaky parallel runs and unreadable failures.

Q: How do page objects interact with API setup?

Use API clients or fixtures to create state, then open UI pages at deep links. Page objects should not call heavy business setup through the UI unless that setup is the behavior under test.

Common Mistakes

  • Modeling the entire application in one class.
  • Exposing raw CSS strings from pages and using different ones in tests.
  • Hiding critical assertions inside multi-step methods.
  • Building deep inheritance for persona differences that are only data.
  • Using index-based table clicks as the default.
  • Putting waits of fixed seconds inside every interaction.
  • Making page objects depend on global mutable state across tests.
  • Rewriting to a heavy pattern when thin pages would do.
  • Zero collaboration with developers on test ids and accessibility roles.
  • Treating page objects as untouchable framework code instead of ordinary design.

Conclusion

Mastering how to write maintainable page objects is less about a pattern name and more about boundaries: screens stay thin, flows orchestrate journeys, tests assert outcomes, and locators follow accessibility-friendly stability. Composition, fixtures, and clear naming will outlive clever base classes.

Pick one volatile screen this week. Extract a thin page object, move assertions into tests, replace a sleep with an expectation, and document the locator preference order for your team. That single refactor teaches the standard better than a slide deck, and it starts the flywheel toward a suite people are not afraid to change.

Versioning Shared Page Libraries Across Teams

When multiple squads consume one ui-model package, treat page objects as a shared API surface.

  • Semantic version page libraries when method signatures change.
  • Provide codemods or find-replace notes for renames.
  • Keep example specs in CI for each critical page.
  • Deprecate methods before removal (applyCouponCode replacing typeCouponAndClick).
  • Do not reach into another team's page private locators from outside.

Shared libraries without compatibility discipline create cross-team freezes. Maintainability is social as well as technical.

Observability Inside Page Actions

Add optional diagnostics without noise:

async applyCouponCode(code: string) {
  await this.couponInput.fill(code);
  await this.applyCoupon.click();
  // useful breadcrumb in traces; avoid console spam of secrets
  await this.page.evaluate((c) => console.debug("coupon_applied", c), code);
}

Prefer built-in trace/timeline tools over custom logging frameworks inside every page. If you log, never log passwords, tokens, or full payment payloads. Maintainable page objects respect security as part of quality.

Checklist for Code Reviewers

When reviewing a PR that touches page objects, ask:

  1. Is this still one screen or component?
  2. Are locators role/label/test-id first?
  3. Do method names describe user intent?
  4. Are new assertions justified inside the page?
  5. Did a flow belong here instead of bloating the page?
  6. Any sleeps, catches that swallow errors, or static shared state?
  7. Parallel-safe construction (no cross-test singletons)?

A five-minute checklist prevents months of entropy. Teaching how to write maintainable page objects through review norms scales better than relying on one principal engineer to rewrite everything later.

Mini Case Study: Cart Page Cleanup

Before: CartPage had 900 lines, including payment iframe helpers, admin discount overrides, Thread.sleep equivalents, and assertions for tax law variants.

After:

  • CartPage for cart controls only
  • PaymentPage for payment
  • checkoutFlow for the journey
  • tax calculations covered by unit tests
  • UI tests assert displayed totals only

Result shape: fewer merge conflicts on the cart file, clearer failures, faster onboarding. Your metrics will differ, but the structural lesson is stable: split by UI responsibility and push pure logic out of pages entirely.

Synchronisation Details That Belong Near Interactions

Even with auto-waiting drivers, some UI needs explicit conditions. Put those conditions next to the interaction that needs them, with clear names.

async openAdvancedFilters() {
  await this.moreFilters.click();
  await expect(this.filtersPanel).toBeVisible();
}

async waitForResults() {
  await expect(this.resultsStatus).toHaveText(/Showing \d+ results/);
}

Do not invent a universal waitForPageToBeReady that guesses every spinner in the product. Specific waits document real product behavior. Generic waits become dumping grounds and often hide performance problems. This specificity is part of how to write maintainable page objects that remain honest under load and slow networks.

Empty States, Permissions, and Alternate Skins

The same route can render different UI for empty data, read-only users, or themed skins. Options:

  • Separate methods: expectEmptyState, expectReadOnly
  • Separate small page variants only when structure diverges heavily
  • Conditional locators carefully scoped and named
async expectEmptyState() {
  await expect(this.page.getByRole("heading", { name: "Your cart is empty" })).toBeVisible();
}

Avoid boolean forests like new CartPage(page, { empty: true, admin: true, dark: true, mobile: true }). Prefer test setup that creates the right state, then a straightforward page API. Maintainable objects model UI that exists, not every hypothetical combination in constructor flags.

Pairing With Component Testing

Where the stack allows component tests (Playwright component testing, Cypress component testing, Testing Library), keep pure UI state checks at that layer and reserve page objects for integration and end-to-end paths. Page objects should not re-test every hover style. They should help prove wiring across routes, auth, and backend contracts. Knowing how to write maintainable page objects includes knowing when not to use them at all because a cheaper layer already owns the risk.

Final Practical Template

When you add a new page object, start from this skeleton and delete what you do not need:

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

export class ExamplePage {
  readonly primaryAction: Locator;

  constructor(private readonly page: Page) {
    this.primaryAction = page.getByRole("button", { name: "Save" });
  }

  async goto() {
    await this.page.goto("/example");
  }

  async expectLoaded() {
    await expect(this.primaryAction).toBeVisible();
  }
}

Add methods only when a test needs them twice or when a selector is non-obvious. That discipline is the everyday practice behind how to write maintainable page objects, and it keeps the library small enough that people still open the files without fear.

Interview Questions and Answers

How do you write maintainable page objects?

I keep them thin and screen-scoped, with stable locators and intention-revealing methods. I place multi-page journeys in flows, keep most assertions in tests, and prefer composition for shared UI fragments. I avoid deep inheritance, global driver state, and sleeps.

Should page objects contain assertions?

Light readiness checks are fine. Business outcome assertions usually belong in tests so failures read clearly and pages do not become opaque one-liners that hide what was verified.

Page object model vs screenplay pattern?

Page objects are simpler and enough for many teams. Screenplay can help very large suites with reusable tasks and actors, but it adds concepts and can be overkill. I choose based on team size and pain, not trendiness.

How do you manage locators that change often?

Partner with developers on roles and test ids, centralize locators in page modules, and add review rules for UI changes touching critical hooks. If copy is volatile, do not key locators on full marketing strings.

How do you avoid god classes?

Split by screen or component, extract flows for journeys, and review file size and responsibility in PRs. If a page object knows checkout, profile, and admin settings, it is already too big.

What is the biggest page object anti-pattern you have seen?

A base page with dozens of unrelated helpers plus static WebDriver access, combined with assertions buried inside methods. It creates flaky parallel runs and unreadable failures.

How do page objects interact with API setup?

Use API clients or fixtures to create state, then open UI pages at deep links. Page objects should not call heavy business setup through the UI unless that setup is the behavior under test.

Frequently Asked Questions

What makes a page object maintainable?

A maintainable page object models one screen or fragment, uses stable locators, exposes intention-revealing actions, and avoids deep inheritance and hidden assertions. It absorbs UI change without forcing edits across dozens of tests.

Should assertions live in page objects?

Use light readiness checks in pages when helpful. Keep business outcome assertions in tests so failures clearly show what was expected and pages do not become opaque wrappers.

Is the page object model still relevant with Playwright in 2026?

Yes, as a light abstraction over locators and interactions. Playwright's auto-waiting reduces some old boilerplate, but shared screens still benefit from page modules and flows when suites grow.

How do I refactor a large god page object?

Split by screen responsibility, extract shared components, move journeys into flows, and relocate business assertions to tests. Strangle the highest-churn file first instead of rewriting everything at once.

Page objects or Cypress custom commands?

Use modules or page objects for screen interactions and reserve custom commands for cross-cutting concerns like session setup. Unbounded commands become global and hard to navigate.

How should dynamic tables be modeled?

Query rows by accessible name, test id, or unique cell text. Avoid index-based clicks except for explicit layout tests, and name index methods so their fragility is obvious.

When should I not create a page object?

For a one-off screen used by a single short test, inline locators can be clearer. Promote to a page object when reuse appears or when shared selectors are volatile.

Related Guides