Q32 of 48 · Cypress
How would you structure a Page Object Model in a TypeScript Cypress project?
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);
});