Q32 of 48 · Cypress

How would you structure a Page Object Model in a TypeScript Cypress project?

CypressMidcypresspage-objectstypescriptpatternsmid

Short answer

Short answer: Use a class or object per page with locators as private fields and actions as methods. Methods return `this` (or the next page) for chaining. Keep assertions in tests, not in page objects. Cypress communities also favour 'app actions' over POMs — call business-level functions directly.

Detail

A canonical Cypress POM in TypeScript:

export class LoginPage {
  private url = '/login';
  private email = () => cy.get('[data-test=email]');
  private password = () => cy.get('[data-test=password]');
  private submit = () => cy.get('[data-test=submit]');

  visit(): this {
    cy.visit(this.url);
    return this;
  }

  loginAs(email: string, pwd: string): DashboardPage {
    this.email().type(email);
    this.password().type(pwd);
    this.submit().click();
    return new DashboardPage();
  }
}

Key conventions:

  • Private locators as functions, not properties — calling this.email() re-queries each time, getting Cypress's auto-retry. A property would snapshot once.
  • Actions return the next page object — fluent chaining (new LoginPage().visit().loginAs(...)).
  • No assertions inside actions — assertions belong in tests so failures point at the test's intent. Page objects expose state checks (isErrorVisible(): boolean) for tests to assert on.

The Cypress community has historically debated POMs. The alternative pattern is app actions — instead of new LoginPage().loginAs(...), use cy.loginAs(...) (a custom command) that hits the API directly. App actions are faster and more deterministic; POMs are more readable for journey tests.

A pragmatic blend: app actions for setup (seeding data, logging in), POMs for the feature actually under test (so the test reads as user actions on a page).

Avoid the trap of POMs that just expose locators (getEmailInput()) — that's a worse version of cy.get and adds maintenance burden without abstraction value.

// EXAMPLE

pages/CartPage.ts

export class CartPage {
  visit(): this {
    cy.visit('/cart');
    return this;
  }

  // Actions
  changeQuantity(itemId: string, qty: number): this {
    cy.get(`[data-test=item-${itemId}] [data-test=qty]`).clear().type(`${qty}`);
    return this;
  }

  removeItem(itemId: string): this {
    cy.get(`[data-test=item-${itemId}] [data-test=remove]`).click();
    return this;
  }

  proceedToCheckout(): CheckoutPage {
    cy.get('[data-test=checkout]').click();
    return new CheckoutPage();
  }

  // State exposure (no assertion inside)
  totalText(): Cypress.Chainable<string> {
    return cy.get('[data-test=total]').invoke('text');
  }

  itemCount(): Cypress.Chainable<number> {
    return cy.get('[data-test=cart-row]').its('length');
  }
}

// In a spec
it('removes an item and updates the total', () => {
  const cart = new CartPage().visit();
  cart.removeItem('p1');
  cart.totalText().should('eq', '£10.00');   // assertion in the test
  cart.itemCount().should('eq', 2);
});

// WHAT INTERVIEWERS LOOK FOR

Locators as functions (not properties) for retry-ability, methods returning the next page, no assertions inside POM, and ideally awareness of app actions as an alternative.

// COMMON PITFALL

Putting assertions inside POM methods — failures then point at `LoginPage.loginAs` instead of the actual test, hiding intent.