Cypress: Page Object Model, 3 pages y 2 errores reales contra Serenity.is

LoginPage, DashboardPage, ClientesPage en TypeScript. Refactoring de 14 a 18 tests. uncaught:exception y empty fields que Cypress no logra limpiar.

Cursor IDE mostrando LoginPage.ts con selectores privados y métodos login, clickLogin y getErrorMessage, carpeta pages con LoginPage, DashboardPage y ClientesPage en el sidebar
LoginPage.ts + carpeta pages/ — de selectores inline a Page Object Model en Cypress

Contexto

Este post documenta la implementación de Page Object Model en el proyecto Cypress + TypeScript.

El post anterior Cypress: configuración profesional, custom commands y estructura por módulos terminó con 14 tests pasando, estructura por módulos, cy.login() como custom command y cypress.config.ts profesional.

Pero los tests siguen llenos de selectores inline:

cy.get('#LoginPanel0_Username').clear().type('admin')
cy.get('#LoginPanel0_Password').clear().type('serenity')
cy.get('#LoginPanel0_LoginButton').click()
cy.get('h1').should('have.text', 'Tablero')
cy.get('#s_sidebar_menu1').should('be.visible')

Si mañana Serenity.is (AUT) cambia un selector, tengo que buscar archivo por archivo dónde se usa. Con 14 tests es manejable. Con 40 no.

Page Object Model resuelve esto: una clase por página, selectores en un solo lugar, tests que llaman métodos en vez de manipular el DOM directamente.

Ya implementé POM en Selenium (BasePage, LoginPage, ClientesPage), en Playwright (3 pages con fixtures) y en Appium (BasePage, LoginPage, ProductsPage). El concepto lo conozco. Acá es la implementación específica de Cypress.


POM en Cypress vs Playwright vs Selenium

Antes de ver el código, las diferencias de implementación:

Concepto Selenium (Java) Playwright (TS) Cypress (TS)
Inyección WebDriver por constructor Page por constructor No hay — cy es global
Async No Sí (async/await) No — comandos chainables
Selectores By.id(), By.cssSelector() page.locator(), getByRole() Strings CSS, métodos usan cy.get()
Constructor Obligatorio (recibe driver) Obligatorio (recibe page) No necesario
Assertions en page A veces (métodos de verificación) Sí (expect dentro de métodos) No — se retorna cy.get(), el test decide

La diferencia más grande: en Cypress los métodos de la page retornan el cy.get() y el test encadena la assertion. En Playwright, metía el expect dentro de la page (verificarVisible()). En Cypress es más natural retornar y encadenar.


LoginPage

Creé cypress/pages/LoginPage.ts:

export class LoginPage {
    private usernameInput = '#LoginPanel0_Username'
    private passwordInput = '#LoginPanel0_Password'
    private loginButton = '#LoginPanel0_LoginButton'
    private errorBanner = '.toast-message'

    visit() {
        cy.visit('/Account/Login')
    }

    login(username: string, password: string) {
        cy.get(this.usernameInput).should('be.enabled').clear().type(username)
        cy.get(this.passwordInput).should('be.enabled').clear().type(password)
        cy.get(this.loginButton).click()
    }

    clickLogin() {
        cy.get(this.loginButton).click()
    }

    getErrorMessage() {
        return cy.get(this.errorBanner)
    }
}
Cursor IDE mostrando LoginPage.ts con selectores privados y métodos login, clickLogin y getErrorMessage, carpeta pages con 3 archivos en el sidebar
LoginPage.ts — 4 selectores, 4 métodos. Sin constructor, sin async, sin page inyectado

LoginPage completa — 4 selectores, 4 métodos. Sin constructor, sin async, sin page inyectado

4 selectores como propiedades privadas, 4 métodos públicos. Sin constructor. Sin page inyectado. Sin async/await. Mucho más liviano que la LoginPage de Playwright.

visit() navega a la página de login. login() hace el flujo completo: tipea usuario, password y hace click. clickLogin() hace click sin tipear — Serenity.is precarga admin / serenity en los inputs, así que un click directo funciona. getErrorMessage() retorna el elemento del toast de error para que el test valide lo que necesite.

Comparación con Playwright

Mi LoginPage de Playwright:

export class LoginPage {
    private readonly page: Page;
    private readonly usernameInput: Locator;
    // ... más locators

    constructor(page: Page) {
        this.page = page;
        this.usernameInput = page.getByRole('textbox', { name: '* Nombre de usuario' });
        // ... más inicializaciones
    }

    async login(username: string, password: string) {
        await this.usernameInput.fill(username);
        // ...
    }
}
VS Code mostrando LoginPage.ts de Playwright con constructor que recibe Page, locators readonly y métodos async con await
La misma LoginPage en Playwright — constructor, Page inyectado, async/await, Locator como tipo. Más infraestructura

Constructor, Page inyectado, async/await, Locator como tipo, readonly. En Cypress nada de eso existe. Los selectores son strings, los métodos usan cy global, y no hay que declarar tipos de retorno.

¿Y cy.login()?

En un post anterior creé cy.login() como custom command. Ahora tengo loginPage.login() como método POM. ¿Se pisan?

No. Conviven:

  • cy.login() se usa en el beforeEach de specs que necesitan estar logueadas como precondición (como assertions.cy.ts).
  • loginPage.login() se usa en specs que testean el login en sí (como login.cy.ts).

Son dos niveles diferentes. El custom command es un atajo global. El método POM es parte de la página.


DashboardPage

cypress/pages/DashboardPage.ts:

export class DashboardPage {
    private title = 'h1'
    private sidebar = '#s_sidebar_menu1'
    private sidebarItems = '#s_sidebar_menu1 li'
    private loginPanel = '#LoginPanel'
    private errorMessage = '.error-message'

    getTitle() {
        return cy.get(this.title)
    }

    getSidebar() {
        return cy.get(this.sidebar)
    }

    getSidebarItems() {
        return cy.get(this.sidebarItems)
    }

    getLoginPanel() {
        return cy.get(this.loginPanel)
    }

    getErrorMessage() {
        return cy.get(this.errorMessage)
    }

    getStatCard(name: string) {
        return cy.contains(name)
    }

    getStatCardValue(cardName: string) {
        return cy.contains(cardName).parent().find('h3')
    }
}
Cursor IDE mostrando DashboardPage.ts con selectores de título, sidebar y stat cards, todos los métodos retornando cy.get()
DashboardPage — todos los métodos retornan cy.get(). El test decide qué validar

DashboardPage — todos los métodos retornan cy.get(). El test decide qué validar

El patrón acá es distinto al de Playwright. En Playwright mi DashboardPage tenía:

async verificarVisible() {
    await expect(this.tableroHeading).toBeVisible();
}
VS Code mostrando DashboardPage.ts de Playwright con constructor, getByRole para locators y verificarVisible con expect adentro
DashboardPage en Playwright — verificarVisible() tiene el expect adentro. En Cypress eso queda en el test

El expect estaba adentro de la page. En Cypress, los métodos retornan el cy.get() y el test encadena:

dashboardPage.getTitle().should('have.text', 'Tablero')
dashboardPage.getSidebar().should('be.visible')

La page expone elementos, el test decide qué hacer con ellos. Es más flexible — un mismo método sirve para should('be.visible'), should('have.text'), should('not.exist'), lo que necesites.


ClientesPage

cypress/pages/ClientesPage.ts:

export class ClientesPage {
    // Selectores base
    private heading = '#GridDiv'
    private searchBox = 'input[name="SearchBox"]'
    private newClientButton = '.s-Northwind-CustomerGrid .add-button'

    // Filtros
    private filtroPais = 'div.quick-filter-item[data-qffield="Country"]'
    private filtroCiudad = 'div.quick-filter-item[data-qffield="City"]'
    private contadorRegistros = 'span.slick-pg-stat'

    // Headers de grilla
    private headerID = 'div.slick-header-column[data-id="CustomerID"]'
    private headerEmpresa = 'div.slick-header-column[data-id="CompanyName"]'

    // Grilla
    private grillaCanvas = 'div.sg-body.sg-main.grid-canvas'
    private grillaFilas = 'div.slick-row'

    verificarVisible() {
        return cy.get(this.heading).should('be.visible')
    }

    getContadorRegistros() {
        return cy.get(this.contadorRegistros)
    }

    getGrillaFilas() {
        return cy.get(this.grillaFilas)
    }
}
Cursor IDE mostrando ClientesPage.ts con selectores de grilla SlickGrid, filtros Select2, headers y 3 métodos base
ClientesPage — selectores mapeados, métodos base. Filtros y ordenamiento vienen en posts futuros

ClientesPage — selectores mapeados, métodos base. Los métodos de filtros y ordenamiento vienen en posts futuros

Selectores mapeados para lo que viene: grilla SlickGrid, filtros Select2, headers de ordenamiento, buscador. Los mismos selectores que usé en la ClientesPage de Playwright, adaptados a strings CSS.

Los métodos son solo los base: verificarVisible(), getContadorRegistros(), getGrillaFilas(). Los métodos pesados — filtrarPorPais(), ordenarPorID(), buscar(), leerGrillaCompleta() — se agregan cuando construya los tests de Clientes en posts futuros.

No tiene sentido crear métodos sin tests que los usen. Código muerto no va en el proyecto.


Refactoring de assertions.cy.ts

Antes (selectores inline):

it('should validate the dashboard title', () => {
    cy.get('h1').should('have.text', 'Tablero')
})

it('should validate the sidebar is visible', () => {
    cy.get('#s_sidebar_menu1').should('be.visible')
})

Después (usando DashboardPage):

import { DashboardPage } from '../../pages/DashboardPage'

describe('Serenity.is Dashboard — Assertions y Hooks', () => {
    const dashboardPage = new DashboardPage()

    beforeEach(() => {
        cy.login('admin', 'serenity')
    })

    context('page structure', () => {

        it('should validate the dashboard title', () => {
            dashboardPage.getTitle().should('have.text', 'Tablero')
        })

        it('should validate the sidebar is visible', () => {
            dashboardPage.getSidebar().should('be.visible')
        })

        it('should validate sidebar has menu items', () => {
            dashboardPage.getSidebarItems().should('have.length.greaterThan', 0)
        })

    })

    // ... resto del archivo
})
Cursor IDE mostrando assertions.cy.ts con import de DashboardPage, instancia en el describe y métodos como getTitle y getSidebar
assertions.cy.ts refactorizado — cero selectores inline. dashboardPage.getTitle().should('have.text', 'Tablero')

const dashboardPage = new DashboardPage() al inicio del describe. Todos los tests usan métodos de la page. El beforeEach sigue usando cy.login() — el custom command convive con POM.

Cero selectores CSS en los tests, excepto en el test de "sidebar link attributes" que usa .s-sidebar-link. Ese selector es tan específico de ese test que no vale la pena meterlo en la page. POM centraliza lo reutilizable, no todo.

10 tests, 10 passing. Sin cambios en las assertions, solo en cómo se accede a los elementos.


Refactoring de login.cy.ts — y 4 tests nuevos

El refactoring de login fue más que mover selectores. Además de usar LoginPage, agregué 4 tests que tenía en Playwright pero no en Cypress: click directo con credenciales precargadas, password incorrecta, usuario incorrecto y campos vacíos.

import { LoginPage } from '../../pages/LoginPage'

describe('Serenity.is Login Page', () => {
    const loginPage = new LoginPage()

    it('should load the login page', () => {
        loginPage.visit()
        cy.get('#LoginPanel').should('be.visible')
    })

    it('should login with valid credentials', () => {
        loginPage.visit()
        loginPage.login('admin', 'serenity')
        cy.url().should('not.include', '/Account/Login')
    })

    it('should login with pre-loaded credentials', () => {
        loginPage.visit()
        loginPage.clickLogin()
        cy.url().should('not.include', '/Account/Login')
    })

    it('should show error with wrong password', () => {
        loginPage.visit()
        loginPage.login('admin', 'wrongpassword')
        loginPage.getErrorMessage().should('contain', 'Nombre de usuario o contraseña inválidos')
    })

    it('should show error with wrong username', () => {
        loginPage.visit()
        loginPage.login('wronguser', 'serenity')
        loginPage.getErrorMessage().should('contain', 'Nombre de usuario o contraseña inválidos')
    })

    it('should show error with empty fields', () => {
        loginPage.visit()
        cy.get('#LoginPanel0_Username').invoke('removeAttr', 'required')
        cy.get('#LoginPanel0_Password').invoke('removeAttr', 'required')
        cy.get('#LoginPanel0_Username').invoke('val', '').trigger('input').trigger('change')
        cy.get('#LoginPanel0_Password').invoke('val', '').trigger('input').trigger('change')
        loginPage.clickLogin()
        loginPage.getErrorMessage().should('be.visible')
    })

    it('should find the login button by text', () => {
        loginPage.visit()
        cy.contains('Sign In').should('be.visible')
        cy.contains('Forgot password?').should('be.visible')
        cy.contains('Google').should('be.visible')
    })

    it('should find elements within the login form', () => {
        loginPage.visit()
        cy.get('#LoginPanel').within(() => {
            cy.get('input[name="Username"]').should('exist')
            cy.get('input[name="Password"]').should('exist')
            cy.get('button[type="submit"]').should('be.visible')
        })
        cy.get('#LoginPanel').find('button[type="submit"]').should('contain', 'Sign In')
    })

})
Cursor IDE mostrando login.cy.ts con import de LoginPage y 8 tests incluyendo wrong password, wrong username y empty fields
login.cy.ts — de 4 tests a 8. Los nuevos: click directo, wrong password, wrong username, empty fields

De 4 tests a 8. Los tests didácticos del Post 2 (tests 7 y 8) mantienen algunos selectores inline porque testean features de Cypress (cy.contains, cy.within, cy.find), no flujos de la app.

Pero correr esto no fue directo. Encontré dos errores.


Error 1: uncaught:exception — Cypress falla por errores de la app

Corrí los tests. Los de login positivo pasaron. Pero wrong password y wrong username fallaron con:

(uncaught exception) Error: Service fetch to '/Account/Login'
resulted in HTTP 400 error: Bad Request!
Cypress Test Runner mostrando error uncaught exception Service fetch to Account Login resulted in HTTP 400 Bad Request
uncaught:exception — Cypress detecta un error JavaScript de Serenity.is y falla el test

uncaught:exception — Cypress detecta un error JavaScript de Serenity.is y falla el test

Serenity.is lanza un error JavaScript cuando el servidor responde HTTP 400 (login inválido). Cypress por default intercepta excepciones no capturadas de la app y falla el test — piensa que algo salió mal.

Pero un 400 en un login inválido es comportamiento esperado. No es un bug, es la app respondiendo correctamente.

La solución: agregar un handler en cypress/support/e2e.ts:

import './commands'

Cypress.on('uncaught:exception', (err) => {
    if (err.message.includes('Bad Request')) {
        return false
    }
})
Archivo e2e.ts en Cursor con handler Cypress.on uncaught:exception que retorna false para errores Bad Request
e2e.ts — return false le dice a Cypress: este error es esperado, no falles el test

e2e.ts — return false le dice a Cypress "este error es esperado, no falles el test"

return false le dice a Cypress: ignorá esta excepción. El filtro includes('Bad Request') es específico — solo ignoro errores de Bad Request, no cualquier excepción.

En Selenium esto no pasa porque Selenium no captura errores JavaScript de la app. En Playwright tampoco — las excepciones de la app no afectan los tests por default.

Con el fix, wrong password y wrong username pasan.


Error 2: empty fields — la pelea con Cypress y Serenity.is

Este fue largo. Serenity.is precarga admin y serenity en los inputs del login. Para testear campos vacíos, necesitaba limpiarlos.

Serenity.is mostrando toast rojo con texto Por favor valide los campos vacíos o inválidos antes de enviar el formulario
El toast existe. Cypress no lo atrapa — aparece y se auto-cierra demasiado rápido

Primer intento — clear():

loginPage.clearFields()  // cy.get(input).clear()
loginPage.clickLogin()
loginPage.getErrorMessage().should('contain', 'valide los campos vacíos')

Timeout. El toast (ventana de alerta) nunca apareció.

Segundo intento — type('{selectall}{backspace}'):

cy.get(this.usernameInput).type('{selectall}{backspace}')

Mismo resultado. El toast no aparece.

Tercer intento — invoke('val', '') con triggers:

cy.get('#LoginPanel0_Username').invoke('val', '').trigger('input').trigger('change')
cy.get('#LoginPanel0_Password').invoke('val', '').trigger('input').trigger('change')
Cypress Test Runner mostrando test empty fields con toast visible arriba pero assertion fallando al buscar el elemento toast-message
El toast aparece en el browser pero Cypress no lo encuentra en el DOM. Timing-sensitive

El toast no se encontró en headless. Pero en cypress open sí aparecía.

El problema era doble:

  1. Los inputs tienen atributo required. La validación nativa del browser bloqueaba el submit antes de que el JavaScript de Serenity.is ejecute su validación.
  2. El toast aparece y se auto-cierra muy rápido — Cypress no lo atrapaba buscando texto específico.

Solución final — removeAttr('required') + invoke('val', '') + should('be.visible'):

it('should show error with empty fields', () => {
    loginPage.visit()
    cy.get('#LoginPanel0_Username').invoke('removeAttr', 'required')
    cy.get('#LoginPanel0_Password').invoke('removeAttr', 'required')
    cy.get('#LoginPanel0_Username').invoke('val', '').trigger('input').trigger('change')
    cy.get('#LoginPanel0_Password').invoke('val', '').trigger('input').trigger('change')
    loginPage.clickLogin()
    loginPage.getErrorMessage().should('be.visible')
})
Cypress Test Runner mostrando 8 tests verdes en Serenity.is Login Page incluyendo wrong password, wrong username y empty fields
8 tests verdes en login — los 3 negativos pasando después de los fixes

El toast aparece, Cypress lo atrapa. should('be.visible') en vez de validar texto específico

invoke('removeAttr', 'required') saca la validación nativa del browser. invoke('val', '') con trigger('input') y trigger('change') vacía los campos y notifica a Serenity.is. should('be.visible') valida que el toast existe sin depender del texto — que cambia según el idioma del browser (español en Edge, inglés en Electron).

No valido el texto del mensaje porque varía: "Por favor, valide los campos vacíos..." en Edge, "Please validate empty or invalid inputs..." en Electron. El mismo tema del Post anterior con Accept-Language.

Por qué en Playwright funcionaba directo

En Playwright, el test de campos vacíos era:

await loginPage.login('', '');

fill('') de Playwright usa el Chrome DevTools Protocol para setear el valor del campo — un mecanismo diferente al DOM que sí limpia el estado interno de Serenity.is. Cypress trabaja a nivel DOM con clear(), type(), invoke('val') — un camino distinto que no siempre produce el mismo resultado.

No es un bug de Cypress. Es una diferencia en cómo cada framework interactúa con la app.


e2e.ts — configuración global

Con el handler de excepciones, e2e.ts quedó así:

import './commands'

Cypress.on('uncaught:exception', (err) => {
    if (err.message.includes('Bad Request')) {
        return false
    }
})

Este archivo se carga automáticamente antes de todos los tests. Antes solo importaba commands.ts. Ahora también maneja excepciones de la app.


Estructura final del proyecto

cypress-typescript-framework/
├── cypress/
│   ├── e2e/
│   │   ├── auth/
│   │   │   └── login.cy.ts          ← usa LoginPage (8 tests)
│   │   └── dashboard/
│   │       └── assertions.cy.ts     ← usa DashboardPage (10 tests)
│   ├── fixtures/
│   │   └── users.json
│   ├── pages/                        ← NUEVO
│   │   ├── LoginPage.ts
│   │   ├── DashboardPage.ts
│   │   └── ClientesPage.ts
│   ├── screenshots/
│   └── support/
│       ├── commands.ts               ← cy.login() (custom command)
│       ├── e2e.ts                    ← uncaught:exception handler
│       └── index.d.ts               ← tipos TypeScript
├── cypress.config.ts
├── package.json
├── .gitignore
└── tsconfig.json

Tres capas para acciones de login:

  1. LoginPage (POM) — selectores y acciones de la página. Usada en tests que testean el login.
  2. cy.login() (custom command) — atajo global. Usado en beforeEach de specs que necesitan estar logueadas.
  3. commands.ts + index.d.ts — definición y tipos del custom command.

No se pisan. Cada una tiene su contexto.


Estado actual

18 tests pasando con Page Object Model:

Terminal mostrando All specs passed con 18 tests verdes: auth login.cy.ts 8 passing y dashboard assertions.cy.ts 10 passing
18 tests, 18 passing — 8 en login (4 nuevos), 10 en dashboard. Todo refactorizado con POM

18 tests, 18 passing — 8 en login (4 nuevos), 10 en dashboard. Todo refactorizado con POM

Qué Antes (Post 4) Después (Post 5)
Pages 0 3 (LoginPage, DashboardPage, ClientesPage)
Tests 14 (4 login + 10 dashboard) 18 (8 login + 10 dashboard)
Selectores en tests inline en cada it() centralizados en pages
Login negativo no existía 3 tests (wrong pass, wrong user, empty fields)
uncaught:exception no manejado handler en e2e.ts
Estructura e2e/ + support/ e2e/ + pages/ + support/

Próximo post:

Data-driven testing. Fixtures en profundidad, cy.fixture() para cargar datos, múltiples datasets para login.