Cypress: selectores, comandos y login completo contra Serenity.is

cy.get(), cy.contains(), cy.find(), cy.within(). Login completo contra Serenity.is. Dos errores reales: URL assertion y cy.find() como child command.

Cypress Test Runner mostrando 4 tests verdes contra Serenity.is con el formulario de login renderizado correctamente
4 tests pasando: carga de página, login completo, búsqueda por texto y búsqueda dentro del formulario

Post

El post anterior Qué es Cypress, setup en TypeScript y primer test terminó con un test que abría la página de login y validaba que el panel cargaba. Una línea de código útil. Ahora toca interactuar con la página: escribir en inputs, hacer click, validar qué pasó después.

Este post cubre cómo Cypress encuentra elementos, cómo interactúa con ellos y cómo se compara con Selenium y Playwright en la práctica.

Nota: Si venís de Selenium o Playwright, las diferencias de modelo van a aparecer naturalmente. No hago una comparación teórica — las diferencias se ven en el código.


Cómo Cypress encuentra elementos

En Selenium usaba By.id(), By.cssSelector(), By.xpath(). En Playwright usaba page.locator(). En Cypress hay cuatro formas principales.

cy.get() — el equivalente a By.cssSelector()

Busca elementos por selector CSS. Es lo que más vas a usar:

cy.get('#LoginPanel0_Username')       // por id
cy.get('input[name="Username"]')      // por atributo
cy.get('.btn-primary')                // por clase
cy.get('button[type="submit"]')       // por tipo

Es lo mismo que document.querySelector() del browser, pero con retry automático.

cy.contains() — buscar por texto visible

Esto no existe en Selenium. En Playwright tenés getByText(), pero en Cypress es un comando nativo:

cy.contains('Sign In').should('be.visible')
cy.contains('Forgot password?').should('be.visible')
cy.contains('Google').should('be.visible')

should() es una assertion — verifica que algo cumpla una condición. should('be.visible') significa "este elemento debería verse en pantalla". Es el equivalente a Assert.assertTrue(element.isDisplayed()) de Selenium o expect(locator).toBeVisible() de Playwright. Lo voy a cubrir en profundidad en el próximo post.

Busca el texto visible en la página. No importa en qué elemento esté — si el texto está ahí, lo encuentra.

Acá me pasó algo: escribí cy.contains('Iniciar sesión') pensando que Serenity.is estaba en español. Falló.

Cypress Test Runner mostrando error de assertion: Expected to find content Iniciar sesión but never did
cy.contains('Iniciar sesión') falló — el botón dice Sign In, no Iniciar sesión

El botón dice "Sign In". Error menor, pero demuestra algo: cy.contains() busca texto exacto. Si el texto no matchea, falla.

cy.within() — limitar el scope de búsqueda

cy.within() dice: "todo lo que busques adentro de este bloque, buscalo solo dentro de este elemento". Es como un sandbox.

En Selenium harías algo como:

WebElement panel = driver.findElement(By.id("LoginPanel"));
WebElement input = panel.findElement(By.name("Username"));

En Cypress:

cy.get('#LoginPanel').within(() => {
  cy.get('input[name="Username"]').should('exist')
})

Más limpio. Menos código. Mismo resultado.

cy.find() — buscar dentro de un elemento

cy.find() es un child command. Eso quiere decir que no se puede usar solo — necesita un elemento padre del cual partir:

// ❌ Esto NO funciona
cy.find('button[type="submit"]')

// ✅ Esto SÍ funciona
cy.get('#LoginPanel').find('button[type="submit"]')

Esto me hizo fallar un test. Tenía cy.find() dentro de un within() y Cypress tiró error: cy.find() failed because it requires a DOM element or document.

Cypress Test Runner mostrando error CypressError: cy.find() failed because it requires a DOM element or document
cy.find() necesita un elemento padre — no se puede usar suelto como cy.get()

El problema fue que lo usé suelto después de un should(), que rompe la cadena.

La corrección fue usar cy.get() dentro de within() y dejar cy.find() encadenado desde un cy.get() afuera:

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')

Selectores: qué usar y qué no

Cypress no soporta XPath. En Selenium usaba XPath seguido, especialmente para elementos sin id o para navegación relativa. En Cypress esa opción no existe.

La jerarquía que uso:

  1. id — lo más estable (#LoginPanel0_Username)
  2. data-* attributes — si existen ([data-testid="login-btn"])
  3. CSS selector por atributo — (input[name="Username"])
  4. Texto visible con cy.contains() — cuando el texto es estable

Serenity.is tiene ids estables, así que uso eso. En apps donde los ids son generados dinámicamente, data-testid sería la primera opción.


Comandos de acción

Cypress tiene comandos para interactuar con elementos después de encontrarlos:

cy.get('#LoginPanel0_Username').clear().type('admin')
cy.get('#LoginPanel0_Password').clear().type('serenity')
cy.get('#LoginPanel0_LoginButton').click()

clear() limpia el input, type() escribe texto, click() hace click. Se encadenan uno después de otro.

En Playwright (typescript):

await page.locator('#LoginPanel0_Username').fill('admin')

fill() de Playwright limpia y escribe en un solo método. En Cypress son dos: clear().type(). Detalle menor.

En Selenium (java):

WebElement usuario = wait.until(
    ExpectedConditions.visibilityOfElementLocated(By.id("LoginPanel0_Username"))
);
usuario.clear();
usuario.sendKeys("admin");

Selenium necesita buscar el elemento, esperar a que sea visible, limpiar y escribir. Cuatro pasos. Playwright lo hace en uno. Cypress en dos. Los tres llegan al mismo resultado.


El modelo de ejecución: por qué no hay await

Esto es lo que más me sorprendió viniendo de Selenium y Playwright.

En Selenium configurás esperas explícitas:

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement usuario = wait.until(
    ExpectedConditions.visibilityOfElementLocated(usuarioInput)
);

Si no configurás eso, el test falla porque Selenium no espera nada.

En Playwright usás await porque los métodos son asíncronos:

await page.locator('#LoginPanel0_Username').fill('admin')

Playwright tiene auto-waiting interno (espera a que el elemento sea visible), pero el await es obligatorio porque JavaScript lo requiere para funciones asíncronas. Timeout por default: 30 segundos para acciones, 5 para assertions.

En Cypress no hay await. No hay WebDriverWait. No hay configuración de esperas.

cy.get('#LoginPanel0_Username').clear().type('admin')

Cypress encola los comandos y los ejecuta en orden. Si cy.get() no encuentra el elemento, reintenta automáticamente hasta el timeout (4 segundos por default). No necesitás hacer nada.

Esto se llama retry-ability. Cypress reintenta el comando hasta que pasa o hasta que se agota el timeout. Sin configuración. Sin imports extra.

Framework Esperas Timeout default Configuración necesaria
Selenium Manuales Ninguno WebDriverWait + ExpectedConditions
Playwright Auto-wait + await 30s acciones, 5s assertions Mínima
Cypress Auto-retry 4s Ninguna

Login completo: el test

Con todo eso, el test de login quedó así:

it('should login with valid credentials', () => {
    cy.visit('/Account/Login')
    cy.get('#LoginPanel0_Username').clear().type('admin')
    cy.get('#LoginPanel0_Password').clear().type('serenity')
    cy.get('#LoginPanel0_LoginButton').click()
    cy.url().should('not.include', '/Account/Login')
})

Cinco líneas. Sin imports de espera, sin configuración de driver, sin await.

El error con la URL

Mi primer intento usaba cy.url().should('include', '/dashboard'). Falló.

El error fue claro: expected 'https://demo.serenity.is/' to include '/dashboard'.

Serenity.is después del login redirige a https://demo.serenity.is/, no a /dashboard. En mi serie de Selenium usé urlContains("#/") que funcionaba porque Serenity usaba hash routing en ese momento. Pero acá la URL es limpia.

La corrección: verificar que la URL ya no sea la de login.

// ❌ Falló
cy.url().should('include', '/dashboard')

// ✅ Funciona
cy.url().should('not.include', '/Account/Login')

Esto también es algo que Cypress hace distinto: la assertion reintenta hasta el timeout. Si la redirección tarda 2 segundos, Cypress espera. No necesité agregar un wait o un sleep.


Hot reload

Algo que noté: al guardar el archivo con Ctrl+S, Cypress re-ejecutó los tests automáticamente. Sin parar, sin volver a correr el comando, sin reiniciar nada.

En Selenium hay que parar la ejecución y volver a correr. En Playwright también (a menos que uses --watch). En Cypress es el comportamiento por defecto.

Esto cambia el ritmo de trabajo. Escribís, guardás, ves el resultado. Sin fricción.


Estado actual

4 tests pasando:

  • should load the login page — valida que el panel carga
  • should login with valid credentials — login completo con validación de URL
  • should find the login button by textcy.contains() con textos visibles
  • should find elements within the login formcy.within() + cy.find() para scope limitado
Cypress Test Runner mostrando el test should find elements within the login form con pasos de within, get y find ejecutados
El test final: cy.within() limita la búsqueda al panel de login, cy.find() encadenado desde cy.get()

El código completo:

describe('Serenity.is Login Page', () => {

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

  it('should login with valid credentials', () => {
    cy.visit('/Account/Login')
    cy.get('#LoginPanel0_Username').clear().type('admin')
    cy.get('#LoginPanel0_Password').clear().type('serenity')
    cy.get('#LoginPanel0_LoginButton').click()
    cy.url().should('not.include', '/Account/Login')
  })

  it('should find the login button by text', () => {
    cy.visit('/Account/Login')
    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', () => {
    cy.visit('/Account/Login')
    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')
  })

})

Próximo post: Assertions y hooks. should() con todas sus variantes, before(), beforeEach(), y cómo organizar tests con describe() y context().