AgendaFlow SaaS · Suite completa de tests E2E · Junio 2026
// cypress/e2e/auth/login.cy.ts import '../support/commands' describe('Login — AgendaFlow', () => { beforeEach(() => { cy.visit('/login') }) it('inicia sesión con credenciales válidas', () => { cy.get('[data-cy=email]').type('dr.garcia@agendaflow.io') cy.get('[data-cy=password]').type('Passw0rd!') cy.get('button[type=submit]').click() cy.url().should('include', '/dashboard') cy.get('[data-cy=user-name]') .should('contain', 'Dra. García') }) it('muestra error con contraseña incorrecta', () => { cy.get('[data-cy=email]').type('dr.garcia@agendaflow.io') cy.get('[data-cy=password]').type('wrong') cy.get('button[type=submit]').click() cy.get('[data-cy=error-msg]') .should('be.visible') .should('contain', 'Credenciales inválidas') }) it('protege dashboard sin autenticar', () => { cy.visit('/dashboard') cy.url().should('include', '/login') }) it('cierra sesión correctamente', () => { cy.login() // custom command cy.get('[data-cy=logout-btn]').click() cy.url().should('eq', 'http://localhost:3000/login') }) })
// tests/auth/login.spec.ts import { test, expect } from '../fixtures/auth-fixtures' test.describe('Login — AgendaFlow', () => { test.beforeEach(async ({ page }) => { await page.goto('/login') }) test('inicia sesión con credenciales válidas', async ({ page }) => { await page.getByTestId('email').fill('dr.garcia@agendaflow.io') await page.getByTestId('password').fill('Passw0rd!') await page.getByRole('button', { name: 'Iniciar sesión' }).click() await expect(page).toHaveURL(/\/dashboard/) await expect(page.getByTestId('user-name')) .toContainText('Dra. García') }) test('muestra error con contraseña incorrecta', async ({ page }) => { await page.getByTestId('email').fill('dr.garcia@agendaflow.io') await page.getByTestId('password').fill('wrong') await page.getByRole('button', { name: 'Iniciar sesión' }).click() await expect(page.getByTestId('error-msg')) .toBeVisible() await expect(page.getByTestId('error-msg')) .toContainText('Credenciales inválidas') }) test('protege dashboard sin autenticar', async ({ page }) => { await page.goto('/dashboard') await expect(page).toHaveURL(/\/login/) }) test('cierra sesión correctamente', async ({ authenticatedPage: page }) => { // fixture reemplaza cy.login() await page.getByTestId('logout-btn').click() await expect(page).toHaveURL(/\/login/) }) })
// cypress/support/commands.ts declare namespace Cypress { interface Chainable { login(email?: string, pass?: string): Chainable selectSlot(date: string, time: string): Chainable } } Cypress.Commands.add('login', ( email = 'dr.garcia@agendaflow.io', pass = 'Passw0rd!' ) => { cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(pass) cy.get('button[type=submit]').click() cy.url().should('include', '/dashboard') }) Cypress.Commands.add('selectSlot', (date, time) => { cy.get('[data-cy=calendar]') .find(`[data-date="${date}"]`).click() cy.contains(time).click() cy.get('[data-cy=confirm-slot]') .should('be.visible') })
// tests/fixtures/auth-fixtures.ts import { test as base, Page } from '@playwright/test' export { expect } from '@playwright/test' type AppFixtures = { authenticatedPage: Page bookingPage: Page } export const test = base.extend<AppFixtures>({ async authenticatedPage({ page }, use) { await page.goto('/login') await page.getByTestId('email') .fill('dr.garcia@agendaflow.io') await page.getByTestId('password').fill('Passw0rd!') await page.getByRole('button', { name: 'Iniciar sesión' }).click() await page.waitForURL(/\/dashboard/) await use(page) }, async bookingPage({ authenticatedPage }, use) { await authenticatedPage.goto('/booking/new') await use(authenticatedPage) } }) // helpers/calendar.ts — reemplaza cy.selectSlot() export async function selectSlot( page: Page, date: string, time: string ) { await page.getByTestId('calendar') .locator(`[data-date="${date}"]`).click() await page.getByText(time).click() await expect(page.getByTestId('confirm-slot')) .toBeVisible() }
it('dispara email al confirmar cita', () => { cy.intercept('POST', '/api/notifications/email', { statusCode: 200, body: { sent: true, messageId: 'msg_abc123' } }).as('emailSend') cy.login() cy.selectSlot('2026-06-20', '10:00') cy.get('[data-cy=confirm-btn]').click() cy.wait('@emailSend').then((interception) => { expect(interception.request.body) .to.include({ to: 'paciente@test.com' }) }) cy.get('[data-cy=success-toast]') .should('contain', 'Cita confirmada') })
test('dispara email al confirmar cita', async ({ authenticatedPage: page, bookingPage }) => { let emailBody: any await page.route('**/api/notifications/email', async route => { emailBody = await route.request().postDataJSON() await route.fulfill({ status: 200, body: JSON.stringify({ sent: true, messageId: 'msg_abc123' }) }) }) const emailPromise = page .waitForResponse('**/api/notifications/email') await selectSlot(page, '2026-06-20', '10:00') await page.getByTestId('confirm-btn').click() await emailPromise expect(emailBody.to).toBe('paciente@test.com') await expect(page.getByTestId('success-toast')) .toContainText('Cita confirmada') })
Todos los [data-cy=x] reemplazados por getByTestId('x') y los selectores de botones por getByRole('button', {name}). Tests más resilientes a cambios de CSS.
Se eliminaron 8 instancias de cy.wait(500) y cy.wait(1000). Playwright auto-espera la accionabilidad de cada elemento, haciendo los tests más rápidos y deterministas.
El custom command cy.login() se convirtió en un fixture de Playwright con tipado TypeScript. Cualquier test puede declarar authenticatedPage sin boilerplate.
En CI se activan trazas al fallar (trace: 'retain-on-failure'). Los reportes HTML se suben como artifacts de la PR, con video del navegador y timeline de red.