El mayor sumidero de tiempo en una migración de Selenium a Playwright no es reescribir selectores: es depurar tests inestables que resultan de portar directamente las llamadas Thread.sleep() y WebDriverWait a Playwright. La espera automática está integrada en cada acción de Playwright, así que las esperas explícitas casi nunca tienen lugar en la nueva suite, y copiarlas esconde problemas reales en lugar de resolverlos. Esta guía cubre cuándo la migración tiene sentido financiero, el enfoque strangler fig que mantiene CI verde durante toda la transición, el mapeo de locators y page objects, y el problema de aislamiento de tests que causa fallos aleatorios cuando el paralelismo por defecto de Playwright se encuentra con los supuestos de estado secuencial de Selenium.

Cuándo migrar y cuándo quedarse

La migración tiene un costo real. Antes de escribir una sola línea de código de Playwright, haz este cálculo honestamente.

Para equipos con Selenium, la migración tiene sentido cuando:

  • La gestión del driver está consumiendo tiempo de ingeniería. Si los desajustes de versión de ChromeDriver rompen regularmente tu CI, eso es un costo recurrente que Playwright elimina por completo.
  • Tu librería de utilidades WebDriverWait + ExpectedConditions se expandió demasiado y sigue generando tests inestables.
  • Necesitas emulación mobile, intercepción de red o testing multi-contexto que Selenium no puede proveer de forma limpia.
  • Tu equipo escribe TypeScript y quiere tipos de primera clase en todo.

La migración no tiene sentido cuando tu suite está en Java o Python y el equipo no va a moverse a TypeScript (Playwright tiene bindings para Java y Python, pero el ecosistema y los ejemplos de la comunidad son más débiles), cuando testas en navegadores que Playwright no soporta (Internet Explorer, Edge legacy), o cuando tu suite es estable, tu CI es rápido y el equipo no tiene problemas urgentes.

Para equipos con Cypress, el cálculo es diferente. Cypress y Playwright resuelven el mismo problema al mismo nivel de abstracción. Migrá cuando:

  • Necesitás cobertura de Safari/WebKit. El motor WebKit de Playwright es la única forma de obtener renderizado real del navegador en Windows sin una máquina macOS.
  • Estás chocando con el paywall de paralelización y necesitás sharding gratuito entre runners de CI.
  • Regularmente escribís tests multi-tab o cross-domain que requieren workarounds en Cypress.
  • Quieres testing de API en el mismo framework, en la misma ejecución de tests.

Quédate en Cypress si tu suite funciona y no topas con ninguna de estas paredes. Migrar 300 tests de Cypress que funcionan para ganar una funcionalidad que no necesitás es costo puro sin retorno.

Una regla razonable: si el tiempo de ingeniería perdido anualmente por los problemas de tu framework actual supera dos semanas de salario, la migración se paga en el primer año.

El cambio de modelo mental: protocolo, esperas y ejecución

Entender por qué Playwright se comporta de forma diferente hace el resto de la migración más rápido.

Selenium se comunica con los navegadores a través del protocolo WebDriver: requests HTTP desde tu proceso de test hacia un proceso driver del navegador, que reenvía comandos al navegador. Cada acción es un viaje de ida y vuelta. Por eso Selenium es lento y las esperas son explícitas: el framework no tiene visibilidad sobre lo que el navegador está haciendo entre comandos. Playwright usa Chrome DevTools Protocol (CDP) para Chromium, y protocolos análogos de bajo nivel para Firefox y WebKit. La conexión es un WebSocket persistente, no HTTP por comando. El proceso de Playwright está acoplado directamente al navegador y puede observar el estado del navegador directamente. Eso es lo que hace posible la espera automática.

Cuando llamas await page.getByRole('button', { name: 'Enviar' }).click(), Playwright no hace clic de inmediato. Hace polling hasta que el elemento existe en el DOM, es visible, no está cubierto por otro elemento y no está deshabilitado, y entonces hace clic. La verificación de accionabilidad está integrada en cada interacción. Casi nunca necesitas esperas explícitas.

Cypress usa un enfoque diferente: corre JavaScript dentro del proceso del navegador (no como cliente externo). Su modelo de cola de comandos secuencia las operaciones sin await porque la cola maneja el orden internamente. Esto hace que Cypress se sienta síncrono aunque no lo sea. Playwright abandona esa abstracción y usa async/await estándar, que es más explícito, se integra con las herramientas estándar de JavaScript y es más fácil de razonar cuando algo falla.

La implicación práctica: cuando migres, borra tus utilidades de espera. No las portes. Si te encuentras agregando esperas explícitas en Playwright para corregir inestabilidad, eso es una señal de que tu locator o la estructura del test tiene un problema más profundo.

Mapeo de locators

La clase By de Selenium y la API de locators de Playwright se superponen en capacidades pero difieren en filosofía. Los locators de Playwright son semánticos por defecto. Te invitan a encontrar elementos como lo haría un usuario: por rol y texto visible, no por detalles de implementación CSS.

// Selenium: selector CSS
driver.findElement(By.cssSelector("button[data-testid='submit-btn']")).click();

// Selenium: XPath
driver.findElement(By.xpath("//button[contains(text(), 'Enviar')]")).click();

// Equivalente Playwright: locator semántico
await page.getByRole('button', { name: 'Enviar' }).click();

// Playwright: test ID (cuando controlás el markup)
await page.getByTestId('submit-btn').click();

// Playwright: CSS como fallback (válido, pero usalo como último recurso)
await page.locator("button[data-testid='submit-btn']").click();

La tabla de mapeo para patrones comunes:

// By.id("username")
await page.locator('#username');
await page.getByLabel('Usuario');  // mejor si existe el label

// By.name("email")
await page.locator('[name="email"]');
await page.getByLabel('Email');

// By.linkText("¿Olvidaste tu contraseña?")
await page.getByRole('link', { name: '¿Olvidaste tu contraseña?' });

// By.partialLinkText("Olvidaste")
await page.getByRole('link', { name: /olvidaste/i });

// By.tagName("h1")
await page.locator('h1');

// By.className("error-message")
await page.locator('.error-message');
// o semánticamente:
await page.getByRole('alert');

Cuando encuentres XPath en los tests de Selenium, resiste el impulso de copiar el string XPath en page.locator(). XPath funciona en Playwright, pero ancla tus tests a la estructura de implementación. Usa la migración como oportunidad para reemplazar XPath frágil con getByRole o getByLabel. Los tests que dependen de locators semánticos sobreviven las refactorizaciones mucho mejor.

Ejecutá npx playwright codegen https://tu-app.com contra tu aplicación. El generador de código escribe locators para cada elemento con el que interactuás y por defecto usa getByRole y getByLabel donde es posible. Usalo para descubrir qué locators semánticos están disponibles antes de escribir los mapeos manualmente.

Migración de Page Objects

Los Page Objects se traducen directamente de Selenium a Playwright. El patrón es el mismo. Las diferencias son: el constructor recibe Page en lugar de WebDriver, pones await en todo, y los locators se definen como objetos Locator de Playwright en lugar de descriptores By.

Una clase POM en Selenium:

// Selenium (bindings de TypeScript)
import { WebDriver, By, WebDriverWait, until } from 'selenium-webdriver';

export class LoginPage {
  private driver: WebDriver;
  private wait: WebDriverWait;

  constructor(driver: WebDriver) {
    this.driver = driver;
    this.wait = new WebDriverWait(driver, 10000);
  }

  async navigate() {
    await this.driver.get('https://lab.becomeqa.com/login');
  }

  async login(email: string, password: string) {
    const emailInput = await this.wait.until(
      until.elementLocated(By.cssSelector('input[name="email"]'))
    );
    await emailInput.sendKeys(email);

    const passwordInput = await this.driver.findElement(
      By.cssSelector('input[type="password"]')
    );
    await passwordInput.sendKeys(password);

    const submitBtn = await this.driver.findElement(
      By.cssSelector('button[type="submit"]')
    );
    await submitBtn.click();
  }

  async getErrorMessage(): Promise<string> {
    const errorEl = await this.wait.until(
      until.elementLocated(By.cssSelector('.error-message'))
    );
    return errorEl.getText();
  }
}

El equivalente en Playwright:

// Playwright
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  private readonly page: Page;
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Contraseña');
    this.submitButton = page.getByRole('button', { name: 'Iniciar sesión' });
    this.errorMessage = page.getByRole('alert');
  }

  async navigate() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorMessage(): Promise<string> {
    return this.errorMessage.textContent() ?? '';
  }
}

Observa lo que desapareció: todas las llamadas WebDriverWait, los wrappers until.elementLocated, y el método .sendKeys (reemplazado por .fill). La versión de Playwright es más corta porque la espera automática que era manual en Selenium ahora es implícita.

Una mejora estructural que vale hacer durante la migración: define los locators como propiedades de clase en el constructor en lugar de inline en los métodos. Eso hace que los locators sean fáciles de ver y actualizar, y los objetos Locator de Playwright son lazy: no consultan el DOM hasta que llamás una acción sobre ellos, así que definirlos en el constructor no tiene costo de rendimiento.

Migración desde Cypress

Las migraciones de Cypress a Playwright son más cortas pero requieren desaprender el modelo de cola de comandos.

Encadenamiento de comandos vs. async/await

// Cypress: sin await, cola de comandos
describe('Login', () => {
  it('inicia sesión correctamente', () => {
    cy.visit('/login')
    cy.get('input[name="email"]').type('usuario@example.com')
    cy.get('input[name="password"]').type('password123')
    cy.get('button[type="submit"]').click()
    cy.url().should('include', '/dashboard')
    cy.get('h1').should('contain', 'Bienvenido')
  })
})

// Playwright: async/await estándar
import { test, expect } from '@playwright/test';

test('inicia sesión correctamente', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('usuario@example.com');
  await page.getByLabel('Contraseña').fill('password123');
  await page.getByRole('button', { name: 'Iniciar sesión' }).click();
  await expect(page).toHaveURL(/dashboard/);
  await expect(page.getByRole('heading', { level: 1 })).toHaveText('Bienvenido');
});

Intercepción de red

// Cypress
cy.intercept('GET', '/api/items', { fixture: 'items.json' }).as('getItems')
cy.visit('/items')
cy.wait('@getItems')

// Playwright
await page.route('**/api/items', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Item Uno' }]),
  });
});
await page.goto('/items');
// No se necesita espera explícita. La espera automática lo maneja.

Fixtures y setup

// Cypress beforeEach
beforeEach(() => {
  cy.login('admin@example.com', 'password')
})

// Playwright: usar fixtures para el setup compartido
import { test as base } from '@playwright/test';

const test = base.extend<{ paginaAutenticada: Page }>({
  paginaAutenticada: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('Contraseña').fill('password123');
    await page.getByRole('button', { name: 'Iniciar sesión' }).click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

test('el dashboard de admin carga', async ({ paginaAutenticada }) => {
  await expect(paginaAutenticada.getByRole('heading')).toHaveText('Dashboard');
});

Los fixtures de Playwright son más componibles que el patrón beforeEach de Cypress. Podés apilarlos, hacer que dependan entre sí y darles scope a un solo test o a un archivo completo. Durante la migración, convierte los bloques de login en beforeEach a un fixture con storageState. Eso serializa las cookies y el localStorage del navegador después de un login y los reutiliza entre tests sin repetir el flujo de login en la UI.

Estrategia de migración: ejecución paralela y el strangler fig

No migres todo de una vez. Ese enfoque produce un período de varias semanas donde nada funciona y tu CI no tiene señal verde. Usa el patrón strangler fig: ejecuta Playwright y tu framework existente en paralelo, migrando un área de funcionalidad a la vez.

Paso 1: Instala Playwright junto a tu framework existente

npm init playwright@latest

Elige "TypeScript", pon los tests en playwright-tests/ (no en tests/ si esa es tu carpeta de Selenium/Cypress), y salta el archivo de GitHub Actions por ahora.

Paso 2: Configura CI para ejecutar las dos suites. Tu pipeline ejecuta Selenium (o Cypress) y Playwright. Las dos deben pasar. Eso mantiene intacta tu señal verde mientras avanza la migración. Paso 3: Elige un módulo inicial. Selecciona un área de funcionalidad con page objects claros y tests estables, como un flujo de login o un proceso de checkout. Migra ese módulo completamente: page objects, tests, datos de prueba. Paso 4: Borra los tests viejos de ese módulo. Una vez que la versión de Playwright está en verde durante dos semanas, elimina las contrapartes de Selenium/Cypress. No dejes las dos corriendo indefinidamente. Los tests duplicados doblan el tiempo de CI y generan overhead de mantenimiento. Paso 5: Repite módulo a módulo hasta que el framework viejo no tenga tests. Elimínalo de package.json y del pipeline de CI.
Llevá un registro de la migración: una hoja de cálculo simple o una tabla en Notion con los nombres de los archivos de test, el estado de migración (no iniciado / en progreso / hecho / eliminado) y el ingeniero responsable. Sin eso, el estado semi-migrado persiste en silencio durante meses.

Para suites grandes de Selenium (1000+ tests), considera un enfoque mixto: usa el codegen de Playwright para grabar nuevos tests para flujos de alto valor, y escribe un script de migración para convertir mecánicamente los tests simples de Selenium (click, fill, assert text) que siguen patrones predecibles. La conversión mecánica no va a producir código Playwright idiomático, pero crea una base funcional que podés limpiar de forma incremental.

Errores comunes en la migración

Esperas hardcodeadas. El error más común al migrar tests de Selenium es copiar Thread.sleep() o await driver.sleep(2000) en Playwright. Esas esperas están ocultando problemas reales: elementos que no son accionables, animaciones que no completaron, requests de red que no se resolvieron. En Playwright, page.waitForTimeout(2000) existe pero casi nunca debería aparecer en el código de tests. Reemplazá cada espera hardcodeada con una aserción explícita de que el elemento que necesitás está en el estado que esperás:

// Mal: copiar el hábito de Selenium
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Enviar' }).click();

// Bien: esperar la condición específica
await expect(page.getByRole('button', { name: 'Enviar' })).toBeEnabled();
await page.getByRole('button', { name: 'Enviar' }).click();

Selectores frágiles. Migrar un test de Selenium que usaba By.xpath("//div[3]/button") con el XPath copiado textualmente en page.locator() lleva la fragilidad consigo. Cualquier cambio estructural en el DOM lo rompe. Usá la migración como una oportunidad para reemplazar selectores frágiles por locators semánticos. Supuestos de orden de tests. Las suites de Selenium frecuentemente comparten estado entre tests: un test que crea un usuario, y el siguiente que inicia sesión como ese usuario. Playwright ejecuta tests en paralelo por defecto y en múltiples workers, así que el estado compartido entre tests causa fallos aleatorios difíciles de reproducir. Cada test necesita crear sus propios datos y limpiar después de sí mismo, o usar storageState de Playwright para reutilizar la autenticación sin compartir estado mutable.

// Mal: depende de que un test anterior haya creado el usuario
test('el usuario puede actualizar el perfil', async ({ page }) => {
  await page.goto('/perfil'); // asume estado de login de un test previo
  // ...
});

// Bien: cada test es autocontenido
test('el usuario puede actualizar el perfil', async ({ page, context }) => {
  await context.addCookies(/* cookies de auth desde storageState */);
  await page.goto('/perfil');
  // ...
});

Manejo incorrecto de iframes. El patrón driver.switchTo().frame() de Selenium tiene un equivalente directo en Playwright, pero es suficientemente diferente como para generar confusión:

// Selenium
driver.switchTo().frame(driver.findElement(By.cssSelector('iframe#payment')));
driver.findElement(By.cssSelector('input[name="card"]')).sendKeys('4242...');
driver.switchTo().defaultContent();

// Playwright
const frame = page.frameLocator('iframe#payment');
await frame.locator('input[name="card"]').fill('4242...');
// No hace falta volver al contexto principal. El frameLocator de Playwright ya tiene scope.

Migración del CI: actualizar el pipeline

Reemplazar Selenium Grid o Cypress Cloud con Playwright en CI es directo. Playwright instala los navegadores como parte de su configuración y corre sin un proceso de driver separado.

Desde Selenium Grid

# Antes: Selenium Grid con Docker
services:
  selenium-hub:
    image: selenium/hub:4
  chrome:
    image: selenium/node-chrome:4

steps:
  - name: Ejecutar tests de Selenium
    run: mvn test -Dwebdriver.hub.url=http://selenium-hub:4444

# Después: Playwright (sin servicios externos)
steps:
  - name: Instalar dependencias
    run: npm ci

  - name: Instalar navegadores de Playwright
    run: npx playwright install --with-deps chromium

  - name: Ejecutar tests de Playwright
    run: npx playwright test

Desde Cypress Cloud con paralelización

# Antes: Cypress con paralelización de pago en Cloud
- name: Cypress run
  uses: cypress-io/github-action@v6
  with:
    record: true
    parallel: true
  env:
    CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

# Después: Playwright con sharding gratuito integrado
strategy:
  matrix:
    shard: [1, 2, 3, 4]

steps:
  - name: Instalar Playwright
    run: npm ci && npx playwright install --with-deps chromium

  - name: Ejecutar shard
    run: npx playwright test --shard=${{ matrix.shard }}/4

  - name: Subir reporte
    uses: actions/upload-artifact@v4
    with:
      name: playwright-report-${{ matrix.shard }}
      path: playwright-report/

Para combinar los reportes de los shards en un único reporte HTML una vez que todos completan:

  - name: Combinar reportes
    run: npx playwright merge-reports --reporter html ./all-blob-reports

Durante la fase de ejecución paralela de la migración, configurá CI para ejecutar las dos suites pero reportar los fallos por separado. Así un test inestable de Selenium no bloquea el avance de la migración a Playwright, y tenés una señal clara de qué framework está causando qué fallos.

No elimines Selenium Grid o Cypress Cloud del pipeline hasta que cada test que corría en el framework viejo esté migrado a Playwright o eliminado intencionalmente. Quitar la infraestructura primero y migrar después es cómo los equipos terminan con funcionalidad sin cobertura.

FAQ

¿Cuánto lleva realmente la migración?

Para una suite de 500 tests de Selenium con un solo ingeniero dedicado, esperá 4 a 6 semanas para el trabajo de migración más otras 2 semanas de estabilización (corregir inestabilidad en el nuevo framework). Las suites de Cypress del mismo tamaño llevan 2 a 4 semanas porque los patrones de locators y el modelo mental de JavaScript son más cercanos. Suites grandes (2000+ tests) sin propiedad clara del equipo pueden extenderse a meses. Planificá un 20 a 30% más de lo que estimás inicialmente.

¿Necesito reescribir cada test o puedo automatizar parte del proceso?

Podés automatizar las partes mecánicas: find/replace de cy.get( por page.locator(, convertir cy.visit a await page.goto, envolver todo en async. Eso resuelve quizás el 30% del trabajo y produce algo que compila. El 70% restante, reemplazar selectores frágiles por semánticos, eliminar esperas hardcodeadas, corregir problemas de orden de tests, requiere juicio humano.

¿Qué pasa con mis clases de Page Object existentes?

Conservá el patrón, reemplazá los imports y el constructor. La inversión estructural en POM no se pierde. Mirá el ejemplo antes/después en la sección de migración de Page Objects. La refactorización es mecánica para la mayoría de los métodos.

¿Debería migrar al component testing de Playwright al mismo tiempo?

No. Migrá tu suite E2E primero. El component testing de Playwright es una herramienta separada con una curva de aprendizaje separada. Intentar migrar dos cosas a la vez ralentiza las dos.

¿Qué pasa si algunos tests genuinamente no se pueden migrar?

Mantenelos en el framework viejo. Ejecutalos en un job de CI separado. No dejés que lo perfecto bloquee lo bueno. Una suite migrada al 90% corriendo en Playwright es significativamente mejor que una suite al 0% porque estás esperando portar tres tests de casos borde.

¿Qué pasa después de la migración?

Una vez que tu suite corre en Playwright, estás en posición de mejorarla: agrega mocking de red para acelerar los tests que llegan a APIs reales, introduce storageState para eliminar flujos de login repetidos, habilita la ejecución paralela de tests con workers: 'auto', y agrega cobertura de tests de API usando el fixture request de Playwright. La migración es un piso, no un techo.

→ See also: Playwright en 2026: Por Qué Se Convirtió en el Framework de Tests #1 | Page Object Model en Playwright: De Caótico a Mantenible | Manejo de Autenticación en Playwright con storageState (Sin Iniciar Sesión en Cada Test) | Ejecución Paralela en Playwright: Workers, Fragmentos y Fragmentación para Mayor Velocidad