O maior problema ao migrar do Selenium para o Playwright não é reescrever seletores. É debugar testes flaky que surgem quando você porta chamadas de Thread.sleep() e WebDriverWait direto para o Playwright. O auto-waiting já está embutido em todas as ações do Playwright, então esperas explícitas quase nunca pertencem à nova suite. Copiá-las esconde problemas reais em vez de resolvê-los.

Quando migrar e quando ficar

Migração tem custo real. Antes de escrever uma linha de código Playwright, faça esse cálculo com honestidade.

Para times Selenium, a migração faz sentido quando:

  • O gerenciamento de drivers está consumindo tempo de engenharia. Se incompatibilidades de versão do ChromeDriver quebram regularmente o CI, isso é um imposto recorrente que o Playwright elimina completamente.
  • Sua biblioteca de utilitários com WebDriverWait + ExpectedConditions está crescendo e ainda produzindo testes flaky.
  • Você precisa de emulação mobile, interceptação de rede ou testes multi-contexto que o Selenium não oferece de forma limpa.
  • Seu time usa TypeScript e quer tipos de primeira classe em todo o projeto.

A migração não faz sentido quando:

  • Sua suite está em Java ou Python e o time não vai migrar para TypeScript. O Playwright tem bindings para Java e Python, mas a profundidade do ecossistema e os exemplos da comunidade são mais fracos.
  • Você testa em navegadores que o Playwright não suporta (Internet Explorer, Edge legado).
  • Sua suite está estável, o CI está rápido e o time não tem dor real.

Para times Cypress, o cálculo é diferente. Cypress e Playwright resolvem o mesmo problema em nível de abstração parecido. Migre quando:

  • Você precisa de cobertura em Safari/WebKit. O motor WebKit do Playwright é a única forma de ter renderização real do navegador no Windows sem uma máquina macOS.
  • Está pagando pelo paralelismo e quer sharding gratuito entre runners de CI.
  • Você escreve regularmente testes multi-tab ou cross-domain que exigem workarounds no Cypress.
  • Quer testes de API no mesmo framework, na mesma execução.

Fique no Cypress se sua suite funciona e você não tem nenhuma dessas dores. Migrar 300 testes Cypress funcionando para ganhar uma funcionalidade que você não precisa é custo puro sem retorno.

Uma regra razoável: se o tempo anual de engenharia perdido com as dores do framework atual supera duas semanas de salário, a migração se paga no primeiro ano.

A mudança de modelo mental: protocolo, esperas e execução

Entender por que o Playwright se comporta diferente acelera o resto da migração.

O Selenium se comunica com navegadores pelo protocolo WebDriver: requisições HTTP do processo do teste para um processo de driver do navegador, que encaminha comandos para o browser. Cada ação é uma ida e volta. É por isso que o Selenium é lento e por que as esperas são explícitas: o framework não tem visibilidade do que o navegador está fazendo entre os comandos.

O Playwright usa o Chrome DevTools Protocol (CDP) para Chromium, e protocolos de baixo nível equivalentes para Firefox e WebKit. A conexão é um WebSocket persistente, não HTTP por comando. O processo do Playwright é estreitamente acoplado ao navegador e consegue observar o estado do browser diretamente. É isso que torna o auto-waiting possível.

Quando você chama await page.getByRole('button', { name: 'Submit' }).click(), o Playwright não clica imediatamente. Ele faz polling até o elemento existir no DOM, estar visível, não estar coberto por outro elemento e não estar desabilitado, e só então clica. A checagem de acionabilidade está embutida em toda interação. Você quase nunca precisa de esperas explícitas.

O Cypress usa uma abordagem diferente: roda JavaScript dentro do processo do navegador (não como cliente externo). Seu modelo de fila de comandos sequencia operações sem await porque a fila cuida da ordenação internamente. Isso faz o Cypress parecer síncrono mesmo não sendo. O Playwright abandona essa abstração e usa async/await padrão, o que é mais explícito, se integra com tooling JavaScript padrão e é mais fácil de entender quando algo dá errado.

A implicação prática: ao migrar, delete seus utilitários de espera. Não os porte. Se você se pegar adicionando esperas explícitas no Playwright para resolver flakiness, isso é sinal de que o locator ou a estrutura do teste tem um problema mais profundo.

Mapeamento de locators

A classe By do Selenium e a API de locators do Playwright se sobrepõem em capacidade, mas diferem em filosofia. Os locators do Playwright são semânticos por padrão. Eles incentivam você a encontrar elementos da forma que um usuário faria, por role e texto visível, não por detalhes de implementação CSS.

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

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

// Playwright equivalente: locator semântico
await page.getByRole('button', { name: 'Submit' }).click();

// Playwright: test ID (quando você controla o markup)
await page.getByTestId('submit-btn').click();

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

A tabela de mapeamento para padrões comuns:

// By.id("username")
await page.locator('#username');
await page.getByLabel('Username');  // melhor se o label existir

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

// By.linkText("Forgot password?")
await page.getByRole('link', { name: 'Forgot password?' });

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

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

// By.className("error-message")
await page.locator('.error-message');
// ou semanticamente:
await page.getByRole('alert');

Quando encontrar XPath nos testes Selenium, resista à tentação de copiar a string XPath para page.locator(). XPath funciona no Playwright, mas ancora seus testes à estrutura de implementação. Use a migração como oportunidade para substituir XPath frágil por getByRole ou getByLabel. Testes que dependem de locators semânticos sobrevivem a refatorações muito melhor.

Rode npx playwright codegen https://sua-app.com contra sua aplicação. O gerador de código escreve locators para cada elemento com que você interage e usa getByRole e getByLabel onde possível. Use-o para descobrir quais locators semânticos estão disponíveis antes de escrever mapeamentos manualmente.

Migração de Page Objects

Page Objects se traduzem diretamente do Selenium para o Playwright. O padrão é o mesmo. As diferenças são: o construtor recebe Page em vez de WebDriver, e você usa await em tudo. Os locators são definidos como objetos Locator do Playwright em vez de descritores By.

Uma classe POM em Selenium:

// Selenium (bindings 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();
  }
}

O equivalente em 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('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    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() ?? '';
  }
}

Perceba o que desapareceu: todas as chamadas de WebDriverWait, os wrappers de until.elementLocated e o método .sendKeys (substituído por .fill). A versão Playwright é mais curta porque o auto-waiting que era manual no Selenium agora é implícito.

Uma melhoria estrutural que vale fazer durante a migração: defina os locators como propriedades da classe no construtor, não inline nos métodos. Isso facilita escanear e atualizar os locators. Os objetos Locator do Playwright são lazy: não consultam o DOM até você chamar uma ação neles, então definir no construtor não tem custo de performance.

Migrando do Cypress

Migrações de Cypress para Playwright são mais curtas, mas exigem desaprender o modelo de fila de comandos.

Encadeamento de comandos vs async/await:

// Cypress: sem await, fila de comandos
describe('Login', () => {
  it('faz login com sucesso', () => {
    cy.visit('/login')
    cy.get('input[name="email"]').type('user@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', 'Welcome')
  })
})

// Playwright: async/await padrão
import { test, expect } from '@playwright/test';

test('faz login com sucesso', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL(/dashboard/);
  await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome');
});

Interceptação de rede:

// 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 One' }]),
  });
});
await page.goto('/items');
// Sem espera explícita. O auto-waiting cuida disso.

Fixtures e setup:

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

// Playwright: use fixtures para setup compartilhado
import { test as base } from '@playwright/test';

const test = base.extend<{ authenticatedPage: Page }>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

test('dashboard do admin carrega', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.getByRole('heading')).toHaveText('Dashboard');
});

As fixtures do Playwright são mais combináveis do que o padrão beforeEach do Cypress. Você pode empilhar fixtures, fazê-las depender umas das outras e escopá-las para um único teste ou para um arquivo inteiro. Durante a migração, converta blocos beforeEach de login para uma fixture com storageState. Ela serializa os cookies e o localStorage do navegador após um login e reutiliza tudo nos testes seguintes, sem repetir o fluxo de login na UI.

Estratégia de migração: execução paralela e o strangler fig

Não migre tudo de uma vez. Essa abordagem produz um período de semanas onde nada funciona e o CI não tem sinal verde. Use o padrão strangler fig: rode Playwright e o framework antigo lado a lado, migrando uma área de funcionalidade por vez.

Passo 1: Instale o Playwright junto com o framework existente.

npm init playwright@latest

Escolha "TypeScript", coloque os testes em playwright-tests/ (não em tests/ se esse for o diretório do Selenium/Cypress), e pule o arquivo GitHub Actions por enquanto.

Passo 2: Configure o CI para rodar as duas suites. Seu pipeline roda Selenium (ou Cypress) e Playwright. Ambos devem passar. Isso mantém seu sinal verde enquanto a migração avança. Passo 3: Escolha um módulo inicial. Selecione uma área de funcionalidade com page objects claros e testes estáveis, algo como um fluxo de login ou processo de checkout. Migre esse módulo completamente: page objects, testes, dados de teste. Passo 4: Delete os testes antigos desse módulo. Uma vez que a versão Playwright estiver verde por duas semanas, remova os equivalentes no Selenium/Cypress. Não deixe os dois rodando indefinidamente. Testes duplicados dobram o tempo de CI e criam overhead de manutenção. Passo 5: Repita módulo por módulo até o framework antigo não ter mais testes. Remova-o do package.json e do pipeline de CI.
Mantenha um tracker de migração: uma planilha simples ou tabela no Notion com nomes de arquivos de teste, status de migração (não iniciado / em andamento / concluído / deletado) e o engenheiro responsável. Sem isso, estados semi-migrados persistem silenciosamente por meses.

Para suites Selenium grandes (1000+ testes), considere uma abordagem híbrida. Use o codegen do Playwright para gravar novos testes para fluxos de alto valor. Escreva um script de migração para converter mecanicamente testes Selenium simples (clique, preenchimento, assertiva de texto) que seguem padrões previsíveis. A conversão mecânica não produz Playwright idiomático, mas cria uma base funcional que você pode limpar gradualmente.

Armadilhas comuns na migração

Esperas hardcoded. O erro mais comum ao migrar testes Selenium é copiar Thread.sleep() ou await driver.sleep(2000) para o Playwright. Essas esperas estão escondendo problemas reais: elementos que não estão acionáveis, animações que não terminaram, requisições de rede que não resolveram. No Playwright, page.waitForTimeout(2000) existe, mas quase nunca deve aparecer em código de teste. Substitua cada espera hardcoded por uma assertion explícita de que o elemento que você precisa está no estado esperado:

// Errado: copiando o hábito do Selenium
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();

// Certo: espere pela condição específica
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();

Seletores frágeis. Migrar um teste Selenium que usava By.xpath("//div[3]/button") com o XPath copiado verbatim para page.locator() carrega a fragilidade junto. Qualquer mudança estrutural no DOM quebra o teste. Use a migração como oportunidade para substituir seletores frágeis por seletores semânticos. Suposições de ordenação de testes. Suites Selenium frequentemente compartilham estado entre testes: um teste cria um usuário, e o próximo faz login como esse usuário. O Playwright roda testes em paralelo por padrão e em múltiplos workers, então estado compartilhado entre testes causa falhas aleatórias difíceis de reproduzir. Cada teste precisa criar seus próprios dados e limpar depois, ou usar storageState do Playwright para reutilizar autenticação sem compartilhar estado mutável.

// Errado: depende de um teste anterior ter criado o usuário
test('usuário pode atualizar perfil', async ({ page }) => {
  await page.goto('/profile'); // assume estado de login de um teste anterior
  // ...
});

// Certo: cada teste é autossuficiente
test('usuário pode atualizar perfil', async ({ page, context }) => {
  await context.addCookies(/* cookies de auth do storageState */);
  await page.goto('/profile');
  // ...
});

Problemas com iframes. O padrão driver.switchTo().frame() do Selenium tem um equivalente direto no Playwright, mas é diferente o suficiente para causar confusão:

// 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...');
// Sem necessidade de voltar. O frameLocator do Playwright tem escopo automático.

Migração de CI: atualizando o pipeline

Substituir o Selenium Grid ou o Cypress Cloud pelo Playwright no CI é direto. O Playwright instala navegadores como parte do setup e roda sem processo de driver separado.

Do Selenium Grid:

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

steps:
  - name: Rodar testes Selenium
    run: mvn test -Dwebdriver.hub.url=http://selenium-hub:4444

# Depois: Playwright (sem serviços externos)
steps:
  - name: Instalar dependências
    run: npm ci

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

  - name: Rodar testes Playwright
    run: npx playwright test

Do Cypress Cloud com paralelização:

# Antes: Cypress com paralelização paga via Cloud
- name: Cypress run
  uses: cypress-io/github-action@v6
  with:
    record: true
    parallel: true
  env:
    CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

# Depois: Playwright com sharding gratuito nativo
strategy:
  matrix:
    shard: [1, 2, 3, 4]

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

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

  - name: Fazer upload do relatório
    uses: actions/upload-artifact@v4
    with:
      name: playwright-report-${{ matrix.shard }}
      path: playwright-report/

Para mesclar relatórios de shards em um único relatório HTML após todos os shards concluírem:

  - name: Mesclar relatórios
    run: npx playwright merge-reports --reporter html ./all-blob-reports

Durante a fase de execução paralela da migração, estruture o CI para rodar as duas suites, mas reporte as falhas separadamente. Assim um teste Selenium flaky não bloqueia o progresso da migração para Playwright, e você tem um sinal limpo de qual framework está causando quais falhas.

Não remova o Selenium Grid ou o Cypress Cloud do pipeline até que cada teste que rodava no framework antigo esteja migrado para o Playwright ou intencionalmente deletado. Remover a infraestrutura primeiro e migrar depois é como times acabam com funcionalidades sem cobertura.

FAQ

Quanto tempo a migração realmente leva?

Para uma suite Selenium de 500 testes com um engenheiro dedicado, espere 4–6 semanas para o trabalho de migração mais outras 2 semanas de estabilização (corrigindo flakiness no novo framework). Suites Cypress do mesmo tamanho levam 2–4 semanas porque os padrões de locator e o modelo mental JavaScript são mais próximos. Suites grandes (2000+ testes) sem ownership clara dos testes podem se estender por meses. Planeje 20–30% a mais do que sua estimativa inicial.

Preciso reescrever cada teste ou posso automatizar parte disso?

Você pode automatizar as partes mecânicas: find/replace de cy.get( por page.locator(, converter cy.visit para await page.goto, envolver tudo em async. Isso cobre talvez 30% do trabalho e cria algo que compila. Os outros 70%, substituir seletores frágeis por semânticos, remover esperas hardcoded, corrigir problemas de ordenação de testes, exigem julgamento humano.

E minhas classes de Page Object existentes?

Mantenha o padrão, substitua os imports e o construtor. O investimento estrutural em POM não é perdido. Veja o exemplo antes/depois na seção de migração de Page Objects acima. O refactor é mecânico para a maioria dos métodos.

Devo migrar para os testes de componente do Playwright ao mesmo tempo?

Não. Migre sua suite E2E primeiro. Os testes de componente do Playwright são uma ferramenta separada com uma curva de aprendizado separada. Tentar migrar as duas coisas simultaneamente atrasa as duas.

E se alguns testes genuinamente não puderem ser migrados?

Mantenha-os no framework antigo. Rode-os em um job de CI separado. Não deixe o perfeito bloquear o bom. Uma suite 90% migrada rodando no Playwright é meaningfully melhor do que uma suite 0% migrada porque você está esperando para portar três testes de edge case.

O que acontece depois da migração?

Com a suite rodando no Playwright, você está posicionado para melhorá-la: adicionar mocking de rede para acelerar testes que chamam APIs reais, usar storageState para eliminar fluxos de login repetidos, habilitar execução paralela com workers: 'auto', e adicionar cobertura de testes de API usando a fixture request do Playwright. A migração é o ponto de partida, não o destino.

→ Veja também: Playwright em 2026: Por Que Se Tornou o Framework de Testes #1 | Page Object Model no Playwright: Do Caos à Manutenibilidade | Autenticação no Playwright com storageState (Sem Login em Cada Teste) | Execução Paralela no Playwright: Workers, Shards e Sharding para Velocidade