Using the Page Object Pattern Correctly Instead of Overengineering It
AI generated
PASS
expect()
Page Objects · Cypress · Playwright · E2E Testing
Using the Page Object Pattern Correctly Instead of Overengineering It
Decoupling selectors without hiding test logic behind abstraction layers

The page object pattern is meant to decouple tests from selectors and DOM structure, but in practice it often balloons into classes with hidden business logic and deep inheritance chains. This article shows what the pattern actually solves, which overengineering traps are common, what a minimal page object looks like, and when a test is better off without the pattern entirely.

12 min read Page Objects · Component Objects · TypeScript Cypress 13.x · Playwright · Magento 2.4.8

1. What the page object pattern actually solves

Without page objects, CSS selectors and XPath expressions end up scattered directly across every test case. If a class name changes in the frontend, say because a Hyva template gets restructured, every test that uses that selector has to be found and fixed individually. In a growing suite with hundreds of tests, this quickly becomes the biggest maintenance burden of all, bigger than the actual test logic itself.

The page object pattern solves exactly this one problem: it encapsulates the knowledge of how a page is structured, which selectors belong to which elements, in a single place. The test itself only talks about business level actions, loginPage.login(email, password), instead of technical details like cy.get('#email').type(email). If a selector changes, only the page object class needs updating, every test that uses it keeps running unchanged. That is the entire purpose of the pattern, nothing more and nothing less.

2. A minimal page object example

A good page object stays deliberately thin: it defines locators as properties and offers methods for actions a user can actually perform on that page. It contains no assertions about business expectations, those belong visibly in the test case, not hidden inside the page object class. One exception is technical wait conditions, for instance confirming a form has loaded before a field gets filled, that kind of safeguard is fine to keep in the page object.

Playwright and Cypress differ only in syntax here, not in the underlying idea. Playwright typically uses a class with the Page instance injected in the constructor, Cypress often gets by with a simple object made of selector strings and methods that wrap cy commands. What matters in both cases: the class stays scoped to the page it represents, a LoginPage only knows the login form area, not the entire checkout flow.


// tests/pages/LoginPage.js (Playwright)

export class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = page.locator('#email');
    this.passwordInput = page.locator('#pass');
    this.submitButton = page.locator('#send2');
    this.errorMessage = page.locator('[data-testid="login-error"]');
  }

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

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

// tests/login.spec.js
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('shows an error for wrong credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('jane@example.com', 'wrong-password');

  // Business assertion lives in the test, not inside the page object
  await expect(loginPage.errorMessage).toContainText('Invalid credentials');
});

3. Common overengineering traps

The most common trap is a BasePage class that every other page object inherits from, which over time becomes a dumping ground for generic helper methods, waitForPageLoad(), scrollToElement(), retryClick(). Every new requirement ends up there because it is the most convenient place, and after a while every page object inherits twenty methods it never actually uses. Inheritance obscures which methods are actually relevant to a specific page, composition with small, targeted helper functions is almost always the more maintainable alternative here.

A second trap is assuming every page inevitably needs its own page object, even one made of a single button. The result is dozens of one method classes that create more overhead navigating between files than they ever save in maintenance effort. A page object earns its existence once a page is reused across multiple tests or encapsulates complex, multi step interactions worth naming, not before.

4. Why business logic does not belong in page objects

Another widespread pattern: a page object calculates on its own whether a discount was applied correctly, or decides based on pricing logic which test path to take. As soon as a page object makes business decisions instead of just encapsulating UI interactions, test infrastructure and test intent get tangled together, and a reader of the test case can no longer tell what is actually being verified from the test alone, they have to read the page object implementation too.

The rule of thumb: a page object answers exclusively the question of how an interaction is technically performed on the page, never whether the result is business correct. Calculations, price comparisons, conditional branching based on test data belong in the test case itself or in separate, clearly named helper functions outside the page object layer. That keeps a page object swappable when UI details change, without dragging business test logic along with it.


// BAD: business logic and assertions leak into the page object
export class CartPage {
  async verifyDiscountApplied(originalPrice, discountPercent) {
    const expected = originalPrice * (1 - discountPercent / 100);
    const actual = await this.totalPrice.textContent();
    if (parseFloat(actual) !== expected) {
      throw new Error('Discount was not applied correctly');
    }
  }
}

// GOOD: page object only exposes what the UI shows, test does the reasoning
export class CartPage {
  constructor(page) {
    this.page = page;
    this.totalPrice = page.locator('[data-testid="cart-total"]');
  }

  async getTotalPrice() {
    const text = await this.totalPrice.textContent();
    return parseFloat(text.replace(',', '.'));
  }
}

// cart.spec.js
const expected = originalPrice * (1 - discountPercent / 100);
expect(await cartPage.getTotalPrice()).toBeCloseTo(expected, 2);

5. Avoiding too many abstraction layers

Some teams add a layer of screenplay style actor classes, task objects, and interaction wrappers on top of page objects, each with its own interface, even though the project only has a modest number of test cases. Every extra abstraction layer increases the number of files a new team member has to understand before they can even read the first test, and makes debugging more tedious, because a failure has to travel through several layers of indirection before it becomes visible.

The rule that holds up in practice: an abstraction layer has to solve a concrete, recurring problem, not a hypothetical one. Page objects solve selector duplication, component objects solve recurring UI building blocks across multiple pages. An extra actor or task layer only pays off in very large suites with several independent teams that need consistent, domain wide language across test cases, for most Magento store projects that is one layer too many.

6. Component objects instead of monolithic page objects

A mini cart, a header with a search field, or a product filter show up on almost every page of a Magento store. If every page object defined these elements again from scratch, it would recreate the exact duplication the pattern is meant to prevent, just one level deeper. Component objects solve this by encapsulating a recurring UI fragment as its own small class, composed into multiple page objects instead of being rewritten each time.

A MiniCartComponent knows only its own locators and actions, open, remove item, read the total, and gets instantiated as a property inside ProductPage, CategoryPage, and CheckoutPage. If the mini cart structure changes in the theme, only the component class needs updating, regardless of how many pages embed it. That keeps page objects small and focused on what is actually specific to that particular page.


// tests/components/MiniCartComponent.js
export class MiniCartComponent {
  constructor(page) {
    this.page = page;
    this.icon = page.locator('[data-testid="minicart-icon"]');
    this.badge = page.locator('[data-testid="minicart-qty-badge"]');
    this.items = page.locator('[data-testid="minicart-item"]');
  }

  async open() {
    await this.icon.click();
  }

  async getItemCount() {
    return parseInt(await this.badge.textContent(), 10);
  }
}

// tests/pages/ProductPage.js
import { MiniCartComponent } from '../components/MiniCartComponent';

export class ProductPage {
  constructor(page) {
    this.page = page;
    this.addToCartButton = page.locator('#product-addtocart-button');
    // Composition: the mini-cart is reused across every page that has one
    this.miniCart = new MiniCartComponent(page);
  }

  async addToCart() {
    await this.addToCartButton.click();
  }
}

7. When to skip the pattern for simple tests

For a single, isolated smoke test that only checks whether a page loads with a 200 status and a certain title is visible, a dedicated page object is pure overhead. A direct selector inside the test itself is just as maintainable in that case, because there is no second place that needs the same selector again. Introducing a page object only once a second or third test touches the same page avoids premature abstraction, which often turns out to be cut wrong later anyway.

Exploratory or one off tests, say a regression test for a very specific bug that occurred once, also rarely benefit from a page object, because they are not reused in the first place. The practical rule is: page objects earn their existence through reuse across multiple tests, not through an imagined consistency rule demanding that every page has one. A project with ten tests generally needs far less page object structure than one with five hundred.

8. TypeScript typing and maintainability

TypeScript does not automatically make page objects better structured, but it does prevent a whole class of bugs that would otherwise only surface at runtime: a wrong method name, a swapped parameter, a locator that was never initialized. Readonly properties for locators, readonly emailInput: Locator, additionally make it visible that a page object sets its locators once in the constructor and never reassigns them at runtime, which prevents unintended side effects between test runs.

Explicit return types for methods that yield a value, for instance Promise for getTotalPrice(), document the interface at the same time and prevent a caller from accidentally processing a string instead of a number further down the line. For page objects maintained by multiple test authors over a long period, this typing pays off noticeably over time, because refactorings, like renaming a method, get flagged by the compiler at every affected call site instead of only showing up in a failing test run.

9. The page object pattern in direct comparison

Not every decision when applying the pattern is obviously right or wrong. The following overview shows typical design decisions and which variant tends to prove more maintainable in suites that have grown over time.

Decision Overengineered Practical Benefit
Shared helper methods BasePage with 20 inherited methods Small, targeted helper functions Only relevant methods visible per page
Recurring UI building blocks Mini cart duplicated in every page object MiniCartComponent via composition One change in a single central place
Business checks verifyDiscount() inside the page object Assertion in the test, locator in the page object Test intent stays readable in the test
One off smoke test Dedicated page object for one button Direct selector inside the test No overhead without reuse
Extra screenplay layer Actor/Task/Interaction for 15 tests Page objects + component objects suffice Less indirection while debugging

Mironsoft

E2E test automation and maintainable test architectures for Magento and Hyva stores

Page objects your team actually understands?

We build lean page object and component object structures with clean TypeScript typing, no business logic in the wrong layer, and no abstraction layers nobody on your team actually needs.

Test Architecture Audit

Review existing page objects for granularity and business logic

Component Object Design

Cleanly encapsulate recurring UI blocks like mini cart and header

TypeScript Setup

Readonly locators and typed return values for safer refactorings

10. Summary

The page object pattern solves a single, concrete problem: bundling selectors and DOM knowledge in one place instead of scattering them across dozens of test files. A good page object stays lean, holding locators and action methods, but no business assertions and no business logic. Component objects additionally encapsulate recurring UI building blocks like a mini cart or header, so that page objects themselves do not turn into duplicates either.

The biggest danger is not the pattern itself but overdoing it: BasePage classes with twenty inherited methods, page objects for one off tests, extra screenplay layers without a concrete need. TypeScript typing with readonly locators and explicit return types makes page objects more robust against refactoring over time, but it does not replace the basic rule that every abstraction layer must solve a real, recurring problem, not an imagined one.

Page Object Pattern, The Essentials at a Glance

Core purpose

Bundle selectors and DOM knowledge in one place, tests stay readable at the business level.

Avoiding overengineering

No BasePage monsters, no business logic, no unnecessary extra layers.

Component objects

Compose recurring UI building blocks like mini cart or header instead of duplicating them.

When to skip it

One off or isolated tests need no dedicated page object, reuse is what justifies one.

11. FAQ: Using the Page Object Pattern Correctly

1What is the page object pattern?
A pattern that bundles a page's selectors and DOM knowledge, so tests use business level actions instead of technical details.
2What concrete problem does it solve?
It prevents selector duplication across many test files. A selector change only requires updating the page object class.
3Should assertions live in page objects?
No, business assertions belong in the test case so test intent stays visible.
4Why is a large BasePage problematic?
It becomes a dumping ground for unused helper methods. Composition with targeted helpers is usually more maintainable than deep inheritance.
5What are component objects?
Small classes that encapsulate recurring UI blocks like mini cart or header, composed into multiple page objects.
6Does every page need a page object?
No, it earns its existence through reuse across multiple tests.
7Why avoid business logic in page objects?
Business decisions in a page object tangle infrastructure with intent and hide what a test actually verifies.
8What benefit does TypeScript bring?
Prevents runtime bugs from wrong method names or parameters. Readonly locators and return types make refactorings safer.
9When is a screenplay layer worthwhile?
Only in very large suites with multiple teams. Most projects only need page objects and component objects.
10Difference between Cypress and Playwright?
The underlying idea is identical, only the syntax differs between page instance classes and simpler objects.