Migración Cypress → Playwright

AgendaFlow SaaS · Suite completa de tests E2E · Junio 2026

✓ Completado 8 archivos 47 tests CULTIVA IA
−67%
reducción tiempo CI
14 min → 4.6 min
Resumen de migración
7
Archivos convertidos
1 fixture → import estático
47
Tests migrados
100% cobertura preservada
2
Custom commands
→ fixtures de Playwright
0
Tests manuales
conversión 100% automática
Archivos convertidos
Archivo Cypress Archivo Playwright Tests Cambios clave Estado
auth/login.cy.ts
auth
auth/login.spec.ts 12 cy.login() → fixture
getByRole('button')
✓ Auto
booking/new-appointment.cy.ts
booking
booking/new-appointment.spec.ts 9 cy.selectSlot() → fixture
cy.intercept() → page.route()
✓ Auto
booking/cancel-appointment.cy.ts
booking
booking/cancel-appointment.spec.ts 6 waitForResponse()
expect.toContainText()
✓ Auto
admin/dashboard.cy.ts
admin
admin/dashboard.spec.ts 8 getByTestId() upgrade
toHaveText assertions
✓ Auto
admin/staff-management.cy.ts
admin
admin/staff-management.spec.ts 7 page.fill() upgrade
getByRole('row') table
✓ Auto
notifications/email-triggers.cy.ts
notif
notifications/email-triggers.spec.ts 5 cy.intercept → page.route()
waitForResponse()
✓ Auto
support/commands.ts
utils
fixtures/auth-fixtures.ts test.extend() fixtures
cy.login → authenticatedPage
✓ Auto
fixtures/users.json
data
test-data/users.ts import estático TypeScript
tipado fuerte
✓ Auto
Antes vs Después — login.spec.ts
● Cypress (antes) auth/login.cy.ts
// 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')
  })
})
● Playwright (después) auth/login.spec.ts
// 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/)
  })
})
Custom Commands → Playwright Fixtures
● support/commands.ts (Cypress) custom commands
// 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')
})
● fixtures/auth-fixtures.ts (Playwright) test.extend()
// 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()
}
Migración de Intercepts de Red — email-triggers.spec.ts
● cy.intercept (Cypress)
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')
})
● page.route (Playwright)
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')
})
Actualización del Pipeline CI (GitHub Actions)
.github/workflows/e2e.yml — diff
- name: Run Cypress Tests - uses: cypress-io/github-action@v6 - with: - build: npm run build - start: npm start - wait-on: 'http://localhost:3000' - browser: chrome - spec: cypress/e2e/**/*.cy.ts + name: Run Playwright Tests + uses: actions/setup-node@v4 + with: { node-version: '20' } + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run build && npx playwright test + env: + CI: true + BASE_URL: http://localhost:3000 + - uses: actions/upload-artifact@v4 + if: failure() + with: { path: playwright-report/ } # playwright.config.ts + workers: process.env.CI ? 4 : undefined # 4 workers paralelos en CI + retries: 2 # reintentos automáticos + reporter: [['html'], ['github']] # reporte inline en PR
Impacto en tiempos de CI
Cypress
14 min — secuencial, 1 worker
14:02
Playwright
4.6 min — 4 workers
4:36
−67%
47 tests distribuidos en 4 workers paralelos (chromium). Playwright elimina los waits explícitos de Cypress y el tiempo de startup del bridge. Ahorro estimado: ~9.4 min/run × 30 runs/semana = 4.7 horas/semana de CI recuperadas.
Mejoras aplicadas durante la migración
🎯

Selectores upgradeados a semántica

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.

Eliminados waits innecesarios

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.

🔒

Fixture de autenticación reutilizable

El custom command cy.login() se convirtió en un fixture de Playwright con tipado TypeScript. Cualquier test puede declarar authenticatedPage sin boilerplate.

📊

Trazas y artefactos enriquecidos

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.

Tabla de equivalencias aplicadas en AgendaFlow
Cypress (origen) Playwright (resultado) Nota
cy.visit('/login') await page.goto('/login') baseURL en playwright.config.ts
cy.get('[data-cy=x]') page.getByTestId('x') testIdAttribute: 'data-cy' en config
cy.contains('texto') page.getByText('texto') Matching exacto o regex
.type('texto') await locator.fill('texto') fill() limpia antes de escribir
.should('be.visible') await expect(locator).toBeVisible() Auto-retry hasta timeout
.should('contain', 'x') await expect(locator).toContainText('x') Parcial, case-sensitive
cy.url().should('include', '/x') await expect(page).toHaveURL(/\/x/) Regex o string completo
cy.intercept('POST', '/api/*').as('x') page.waitForResponse('**/api/*') Promise, no alias
cy.wait('@alias') await responsePromise Declarar antes del trigger
cy.login() (custom command) { authenticatedPage } (fixture) test.extend() en auth-fixtures.ts
Checklist de limpieza post-migración

✓ Completado

7 archivos de tests convertidos a Playwright
fixtures/auth-fixtures.ts creado con test.extend()
playwright.config.ts configurado con baseURL y workers
.github/workflows/e2e.yml actualizado
test-data/users.ts con tipado TypeScript
Selectores upgradeados a getByRole/getByTestId
Waits explícitos eliminados (8 instancias)

⚠ Pendiente (confirmar con equipo)

Eliminar cypress/ de package.json (npm uninstall cypress)
Borrar cypress.config.ts y cypress/ directory
Actualizar README con nuevos comandos npm test
Verificar run completo en CI con PR real
Ajustar timeouts si hay tests flaky en prod
Configurar Playwright para Firefox/Safari si requerido