Skip to content

Page Object Pattern

Abstracting UI interactions through declarative, reusable test components

Related Concepts: Acceptance Testing | Frontend Testing | Clean Architecture

Table of Contents

  1. What is a Page Object?
  2. Why Page Objects Matter
  3. Core Principles
  4. Anatomy of a Page Object
  5. Implementation Patterns
  6. Page Objects in the Testing Architecture
  7. Composition and Reusability
  8. Best Practices
  9. Summary

What is a Page Object?

A Model of the UI

A page object is a design pattern that creates an abstraction layer between test specifications and the user interface. It encapsulates the structure and behaviors of a web page (or component) into a reusable class that exposes a declarative API for test interactions.

Think of page objects as the UI's representative in your test code. Instead of tests knowing about buttons, forms, and CSS selectors, they interact with a model that speaks in business terms: "login", "add to cart", "submit application".

Beyond Just Pages

Despite the name, page objects aren't limited to entire pages. They can represent:

  • Complete pages: LoginPage, DashboardPage, CheckoutPage
  • Page sections: NavigationMenu, SearchBar, ProductCard
  • Reusable components: DatePicker, Modal, DataTable
  • Complex widgets: MultiStepForm, ImageGallery, FilterPanel

The pattern applies whenever you need to abstract UI interactions for testing purposes.

The Abstraction Layer

Page objects serve as the translation layer between what tests want to do (business actions) and how to do it (technical implementation):

Test says: "Login with valid credentials"

Page Object translates to: "Enter text in username field, 
                           enter text in password field, 
                           click submit button"

Browser executes: Technical commands with selectors

This abstraction is crucial for maintainable tests.

Why Page Objects Matter

Single Source of Truth

When a UI element changes—and they always do—you update it in exactly one place: the page object. Without page objects, you might need to update dozens or hundreds of tests that interact with that element.

Consider a login button that changes from a class selector to a data-testid:

  • Without page objects: Update every test that clicks login
  • With page objects: Update only the LoginPage class

This difference compounds as applications grow.

Declarative Test Writing

Page objects enable tests to express intent rather than implementation. Tests become readable documentation of system behavior:

// Without page objects (imperative)
await page.locator('#email').fill('user@example.com');
await page.locator('#password').fill('secret123');
await page.locator('button[type="submit"]').click();
await expect(page.locator('.error-message')).toBeVisible();

// With page objects (declarative)
await loginPage.attemptLogin('user@example.com', 'secret123');
await loginPage.verifyErrorDisplayed();

The second version clearly communicates what the test is doing, not how it's doing it.

Reusability Across Tests

Common interactions are defined once and used everywhere. A login method on the LoginPage can be used by:

  • Authentication tests
  • Tests that need authenticated state
  • Security tests verifying session handling
  • Performance tests measuring login time

This reusability ensures consistency and reduces duplication.

Resilience to Change

Page objects create a buffer between tests and UI volatility. When the UI changes:

  • Tests using business-focused methods remain stable
  • Only the page object implementation needs updating
  • The test's intent stays clear and unchanged

This resilience is essential for long-term test maintenance.

Core Principles

1. Encapsulate Page Structure

Page objects hide the details of page structure. Tests shouldn't know about:

  • CSS selectors
  • DOM hierarchy
  • Element IDs or classes
  • XPath expressions
  • Wait conditions

These implementation details belong inside the page object, not in test specifications.

2. Expose Business Actions

Methods should represent user actions in business terms:

Good method names:

  • login(username, password)
  • searchForProduct(productName)
  • addToCart(item)
  • completeCheckout()

Poor method names:

  • clickButton()
  • fillTextField()
  • waitForElement()
  • getElementByClass()

The API should reflect what users do, not how the page works.

3. Return Relevant Types

Page object methods should return appropriate types:

  • Navigation methods return new page objects
  • Action methods return the current page (for chaining) or void
  • Query methods return data or boolean values
  • Verification methods return assertions or throw errors

This typing helps create fluent, intuitive test code.

4. Keep Page Objects Focused

Each page object should have a single, clear responsibility. Don't create god objects that represent the entire application. Instead:

  • Create focused page objects for logical sections
  • Use composition to combine smaller objects
  • Extract shared components into separate classes
  • Keep methods cohesive and related

5. Handle Waiting Internally

Page object methods should handle their own waiting and synchronization:

  • Methods return/resolve only when the action is complete
  • Waiting logic stays inside the page object, not in tests
  • Tests can assume the page is ready after each method call
  • Modern frameworks (Playwright, Cypress) handle most waiting automatically

Anatomy of a Page Object

Essential Components

A well-structured page object contains:

1. Locators: Private properties that define how to find elements

- Define once, use throughout the class
- Update in one place when UI changes
- Use stable selectors (data-testid preferred)

2. Constructor: Initialization logic

- Accept necessary dependencies (page/driver instance)
- Set up initial state
- Define locators

3. Action Methods: User interactions

- Click buttons
- Fill forms
- Navigate
- Interact with elements

4. Query Methods: Retrieve information

- Get text content
- Check element states
- Read form values
- Extract data

5. Verification Methods: Assertions for testing

- Verify element visibility
- Check content presence
- Validate states
- Assert conditions

Method Categories

Page objects typically include these method types:

Navigation:

  • goto(): Navigate to the page
  • clickLoginLink(): Navigate to another page
  • Returns new page objects

Interaction:

  • createAccount(): Register new user
  • chooseShippingSpeed(): Select delivery option
  • completeCheckout(): Finalize purchase

State Queries:

  • isLoggedIn(): Check conditions
  • getErrorMessage(): Read content
  • hasProduct(): Verify presence

Implicit Waiting: Modern testing frameworks handle waiting automatically. Page object methods should complete (resolve their promises) only when the page is ready for the next action. Waiting is an implementation detail, not part of the public API.

Implementation Patterns

The Constructor Pattern

Page objects are typically instantiated with a driver or page instance:

LoginPage takes a browser page/driver
ProductPage takes a browser page/driver and maybe product context
CheckoutFlow might compose multiple page objects

This pattern ensures each page object has what it needs to interact with the UI.

Method Chaining

Enable fluent interfaces by returning this from action methods:

await productPage
  .searchForProduct('laptop')
  .filterByBrand('Apple')
  .sortByPrice('ascending')
  .selectFirstResult();

This creates readable, sequential test flows.

Composition Over Inheritance

Instead of deep inheritance hierarchies, compose page objects:

class ProductPage {
  constructor(page) {
    this.header = new Header(page);
    this.searchBar = new SearchBar(page);
    this.productGrid = new ProductGrid(page);
  }
}

This approach keeps objects focused and promotes reusability.

Page Sections as Separate Objects

Complex pages benefit from section objects:

NavigationMenu - Handles site navigation
ProductFilter - Manages filtering controls  
ShoppingCart - Encapsulates cart widget
UserProfile - Represents profile section

These can be composed into full page objects or used independently.

Page Objects in the Testing Architecture

Part of the Driver Layer

In our layered testing architecture, page objects live in the driver layer:

Test Specification Layer

Driver Layer ← Page Objects live here

Infrastructure Layer

They bridge the gap between high-level test specifications and low-level browser automation.

Relationship to Other Abstractions

Page objects work alongside other driver layer components:

Workflows: Orchestrate multiple page objects for complex scenarios

CheckoutWorkflow uses CartPage, ShippingPage, PaymentPage

Builders: Create test data that page objects consume

UserBuilder creates data that LoginPage uses

API Clients: Handle non-UI operations that support UI tests

API creates user, LoginPage verifies login works

When to Use Page Objects

Use page objects when:

  • Testing UI behavior specifically
  • Running end-to-end acceptance tests
  • Dealing with complex UI interactions
  • Multiple tests interact with the same UI elements

Don't use page objects when:

  • Testing APIs directly
  • Working with simple, one-off interactions
  • The abstraction adds no value
  • Testing non-UI components

Composition and Reusability

Building Blocks Approach

Create small, focused page objects that combine into larger structures:

Atoms: Button, TextField, Dropdown
Molecules: LoginForm, SearchBar, ProductCard
Organisms: Header, ProductGrid, CheckoutFlow
Pages: HomePage, ProductPage, CheckoutPage

This atomic approach maximizes reusability while maintaining clarity.

Shared Components

Extract common UI patterns into reusable components:

  • Navigation: Shared across all pages
  • Modals: Consistent interaction pattern
  • Forms: Standard form handling
  • Tables: Data display and interaction
  • Notifications: Toast/alert handling

These components become your testing vocabulary.

Cross-Page Workflows

Some interactions span multiple pages. Use workflow objects that coordinate page objects:

PurchaseWorkflow coordinates:
- ProductPage (browse and select)
- CartPage (review items)
- CheckoutPage (enter details)
- ConfirmationPage (verify success)

This keeps individual page objects focused while enabling complex test scenarios.

Best Practices

DO ✅

Design Principles

  • Use business language in method names (e.g., placeOrder() not clickSubmit())
  • Keep page objects focused on a single responsibility
  • Handle waiting implicitly - methods complete when ready for next action
  • Return appropriate types from methods
  • Make locators private to the page object

Implementation

  • Create reusable components for common UI patterns
  • Use stable selectors (prefer data-testid attributes)
  • Compose page objects from smaller components
  • Provide clear error messages when assertions fail
  • Initialize state properly in constructors

Maintenance

  • Update page objects first when UI changes
  • Extract common patterns into base classes or utilities
  • Document complex interactions with comments
  • Version page objects with application versions
  • Review and refactor regularly

DON'T ❌

Design Anti-patterns

  • Don't expose page internals (selectors, elements, wait methods)
  • Don't create god objects that do everything
  • Don't mix concerns (UI and API in same object)
  • Don't hardcode test data in page objects
  • Don't make assertions in action methods
  • Don't expose explicit wait methods - handle waiting internally

Implementation Mistakes

  • Don't use brittle selectors (nth-child, CSS classes)
  • Don't duplicate selector definitions
  • Don't create deep inheritance hierarchies
  • Don't ignore error handling
  • Don't skip wait conditions

Maintenance Issues

  • Don't let page objects grow unbounded
  • Don't ignore flaky methods
  • Don't test the framework (browser, test runner)
  • Don't create circular dependencies
  • Don't abandon page objects when they become complex

Common Pitfalls

Assertion Mixing

Keep assertions separate from actions:

// ❌ Wrong: Assertion inside action
async login(username, password) {
  await this.fillCredentials(username, password);
  await this.clickSubmit();
  expect(await this.isLoggedIn()).toBe(true); // Don't do this
}

// ✅ Right: Separate action and verification
async login(username, password) {
  await this.fillCredentials(username, password);
  await this.clickSubmit();
}

async verifyLoginSuccessful() {
  expect(await this.isLoggedIn()).toBe(true);
}

Over-abstraction

Don't abstract everything. Simple, one-time interactions might not need page objects:

// For a simple cookie banner that appears once:
await page.locator('[data-testid="accept-cookies"]').click();

// No need for a CookieBanner page object if it's used once

Leaky Abstractions

Don't let implementation details leak into method names or returns:

// ❌ Leaky abstraction
getSubmitButtonElement()
waitForAjaxComplete()
clickByXPath()

// ✅ Proper abstraction
completeCheckout()
saveProfileChanges()
addProductToCart()

Summary

Page objects are a fundamental pattern for maintainable UI test automation. They:

  1. Abstract UI complexity behind declarative APIs
  2. Centralize UI interactions in reusable components
  3. Insulate tests from UI changes through encapsulation
  4. Enable business-focused test writing with clear methods
  5. Promote reusability across test suites

As part of the driver layer in our testing architecture, page objects bridge the gap between what we want to test (business behavior) and how we test it (browser automation). They transform brittle, imperative test scripts into maintainable, declarative specifications.

The key to successful page objects is balance: abstract enough to hide complexity, concrete enough to be useful, focused enough to be maintainable. When done right, page objects become the vocabulary through which tests express user interactions, making test suites that are both powerful and maintainable.

Remember: page objects are a means to an end. The goal isn't perfect abstractions—it's maintainable tests that provide confidence in your application's behavior. Use page objects when they add value, skip them when they don't, and always focus on creating tests that clearly express business intent.


For practical implementation guidance, see our framework-specific testing guides. For the broader testing strategy, see our Acceptance Testing guide.