Page Object Model in Cypress

9 min read

Page Object Model is one of the longest-standing patterns in test automation. The idea is simple: every page in your app gets a corresponding object in your test code that knows how to find its elements and perform its actions. Tests interact with the object, not the DOM directly. The benefit shows up when a designer reorganises the login form — you fix one place (the page object), not fifty (every spec that types into the login form). This lesson covers both the object and class flavours, the Cypress-specific tweaks, and when POM is overkill versus when it carries its weight.

What POM solves

Without POM, a typical login test looks like this:

it("logs in with valid credentials", () => {
  cy.visit("/login");
  cy.get("[data-testid='email']").type("alice@test.com");
  cy.get("[data-testid='password']").type("Sup3rS3cret!");
  cy.get("[data-testid='submit']").click();
  cy.url().should("include", "/dashboard");
});

Now imagine ten more specs that do the same five lines. The day the email field's data-testid changes from email to email-input, you change eleven files. POM moves all of that into one place:

import { loginPage } from "../pages/loginPage";
 
it("logs in with valid credentials", () => {
  loginPage.visit();
  loginPage.login("alice@test.com", "Sup3rS3cret!");
  cy.url().should("include", "/dashboard");
});

The selector still lives in the test framework — it's just no longer in the spec. Update the page object once; every spec that uses it gets the fix automatically.

The Cypress team recommends plain objects over classes — they're lighter, work naturally with TypeScript, and don't introduce inheritance hierarchies you don't need:

// cypress/pages/loginPage.ts
export const loginPage = {
  visit: () => cy.visit("/login"),
 
  getEmailInput: () => cy.get("[data-testid='email']"),
  getPasswordInput: () => cy.get("[data-testid='password']"),
  getSubmitButton: () => cy.get("[data-testid='submit']"),
  getErrorMessage: () => cy.get("[data-testid='error-message']"),
 
  login: (email: string, password: string) => {
    loginPage.getEmailInput().type(email);
    loginPage.getPasswordInput().type(password);
    loginPage.getSubmitButton().click();
  },
};

Three groups of methods:

  • Navigationvisit(), sometimes goToCart(), goToProfile(). Nothing fancy.
  • Element accessorsgetEmailInput, getPasswordInput. Each one returns a fresh Cypress chainable. They are always functions, never cached values. The reason is critical (the next section covers it).
  • Composite actionslogin(email, password). Combines several element interactions into a higher-level verb.

Element accessors return Chainables, not interactions. Tests that need extra assertions or chained behaviour can call loginPage.getEmailInput().should("be.empty"). Tests that just need to do the typical action call the higher-level loginPage.login(...).

Why every accessor is a function — never a cached element

The single most common POM bug:

// ❌ Don't
export const loginPage = {
  emailInput: cy.get("[data-testid='email']"),     // evaluated at import time
};

cy.get doesn't return a DOM element — it returns a Cypress chainable that runs when the test executes. Caching it at module-load time captures a chainable bound to whatever Cypress thinks the DOM is at the moment of import (which is "nothing" — the test hasn't started). Using it in any test produces "element not found" or stale-DOM errors.

// ✅ Do
getEmailInput: () => cy.get("[data-testid='email']"),

The function form runs the cy.get when the test calls it, returning a fresh chain that re-queries the live DOM and benefits from auto-retry. Burn this in: POM accessors are functions that return Chainables. They are never cached values.

Class-based POM

Some teams prefer classes — they group state and methods, can use inheritance for a shared BasePage, and feel more familiar to engineers coming from Selenium/Java backgrounds. Both forms work:

// cypress/pages/LoginPage.ts
export class LoginPage {
  visit() {
    cy.visit("/login");
    return this;
  }
 
  fillEmail(email: string) {
    cy.get("[data-testid='email']").type(email);
    return this;
  }
 
  fillPassword(password: string) {
    cy.get("[data-testid='password']").type(password);
    return this;
  }
 
  submit() {
    cy.get("[data-testid='submit']").click();
    return this;
  }
 
  getError() {
    return cy.get("[data-testid='error-message']");
  }
}
 
export const loginPage = new LoginPage();

A class lets you chain page-object methods (loginPage.visit().fillEmail(...).fillPassword(...).submit()) by returning this from each. Whether that fluent chain is more readable than four lines is taste; both produce equally correct tests.

The choice between object and class is mostly stylistic. Pick one and stay consistent across the project. Cypress's own recipes use objects; many enterprise QA teams use classes; either is fine. What's not fine is mixing the two in the same project.

A complete login spec using POM

The same two tests from chapter 2, refactored to use the page object:

import { loginPage } from "../pages/loginPage";
 
describe("Login", () => {
  beforeEach(() => {
    loginPage.visit();
  });
 
  it("logs in with valid credentials", () => {
    loginPage.login("alice@test.com", "Sup3rS3cret!");
    cy.url().should("include", "/dashboard");
  });
 
  it("shows an error for invalid credentials", () => {
    loginPage.login("wrong@test.com", "WrongPass");
    loginPage.getErrorMessage().should("contain", "Invalid credentials");
  });
 
  it("disables submit when the email field is empty", () => {
    loginPage.getPasswordInput().type("Sup3rS3cret!");
    loginPage.getSubmitButton().should("be.disabled");
  });
});

Each test reads as a sequence of business-level actions — visit, log in, assert. Selectors are nowhere in the spec file. The day the form refactors, the change lands in pages/loginPage.ts only.

Folder layout

A scalable structure for a moderately-sized suite:

cypress/
├── e2e/
│   ├── auth/
│   │   └── login.cy.ts
│   └── checkout/
│       └── place-order.cy.ts
├── pages/
│   ├── loginPage.ts
│   ├── productListPage.ts
│   ├── cartPage.ts
│   └── checkoutPage.ts
├── fixtures/
└── support/
    └── commands.ts

The pages/ folder mirrors the conceptual pages of the app, not the URL structure. A modal that's reused on multiple pages can have its own object too: pages/components/cookieBanner.ts. Don't try to map every component — only ones that have meaningful state and several methods.

Typing page objects in TypeScript for QA covers the typed-class-with-generics version of the same pattern with a Playwright lens; the Cypress-side principles are identical.

Page objects vs custom commands — when to use which

The two patterns overlap; pick by scope:

  • Custom commands are global. cy.login works the same on any page. Authentication, database seeding, generic helpers — these belong on cy.* because they're not tied to a specific URL.
  • Page objects are page-specific. loginPage.fillEmail is meaningful only on /login. The methods describe that page's actions.

A real project uses both: a cy.loginViaApi custom command for fast setup across all specs, and a loginPage for the dedicated UI-login spec that exercises the form itself.

POM architecture at a glance

The page object adds one layer of indirection. The runtime cost is zero — every method ultimately calls the same cy.* commands you'd write directly. The maintenance gain is what makes the indirection worth it.

When POM is overkill

Three or four specs that each touch a single form? POM is not worth the folder. The "fix one place when the form changes" benefit only shows up after the same selectors appear in many specs. For tiny suites, inline cy.get is shorter and just as clear.

The right time to introduce POM is when the same selector or sequence appears in three or more specs. Before that, custom commands or just inline calls are simpler. After it, POM repays itself within the first refactor.

⚠️ Common mistakes

  • Caching cy.get(...) as a property of the page object. Evaluated at import time, the chainable is bound to nothing useful and every test fails with "element not found." Page-object accessors must always be functions: getEmailInput: () => cy.get(...).
  • Putting too many assertions inside page-object methods. A loginPage.login() that also asserts "URL is /dashboard, welcome banner shows, no error toast" hides what the test is checking. Keep methods focused on action; let the test own its assertions.
  • Translating Selenium POM literally — heavy class hierarchies, BasePage with abstract methods, locator factories. Cypress's chainable model doesn't reward those abstractions. The simpler the page object, the better. Plain objects with element accessors and composite actions are usually enough.

🎯 Practice task

Refactor a real spec into POM. 25-30 minutes.

  1. In your scaffolded project, create cypress/pages/. Add loginPage.ts and productListPage.ts for Sauce Demo. Use the object form for loginPage, the class form for productListPage — get a feel for both.
  2. loginPage should expose: visit(), getUsernameInput(), getPasswordInput(), getLoginButton(), getErrorMessage(), and a composite login(username, password).
  3. productListPage (class) should expose: visit(), getProductCards(), addToCart(productName), getCartBadgeCount(), and goToCart().
  4. Refactor cypress/e2e/login.cy.ts and your existing checkout spec to use these page objects. Imports should look like import { loginPage } from "../pages/loginPage". Confirm both specs still pass.
  5. Force a refactor — change Sauce Demo's username data-test value in your selectors (mentally pretend the app team renamed it from username to email). Update the only file that references it (the page object). Re-run the suite. Every test fixes itself.
  6. Page-object accessor bug drill — temporarily replace getUsernameInput: () => cy.get("[data-test='username']") with usernameInput: cy.get("[data-test='username']") (no function). Run the spec — note the error. Revert. This is the bug every team hits exactly once.
  7. Stretch: add a third page object — cartPage.ts — exposing visit(), getItemRows(), removeItem(productName), proceedToCheckout(). Use it in a test that adds an item, removes it, asserts the cart is empty.

The next lesson goes a level deeper than POM — App Actions, the pattern for skipping the UI entirely when the UI isn't what you're testing.

// tip to track lessons you complete and pick up where you left off across devices.