The biggest time sink in a Selenium-to-Playwright migration is not rewriting selectors: it's debugging flaky tests that result from porting Thread.sleep() and WebDriverWait calls directly into Playwright. Auto-waiting is built into every Playwright action, so explicit waits almost never belong in the new suite, and copying them across hides real problems rather than solving them. This guide covers when migration makes financial sense, the strangler fig approach that keeps CI green throughout the transition, locator and page object mapping, and the test isolation issue that causes random failures when Playwright's default parallelism meets Selenium's sequential state assumptions.

When to migrate, and when to stay

Migration has a real cost. Before writing a single line of Playwright code, run this calculation honestly.

For Selenium teams, migration makes sense when:

  • Driver management is burning engineer time. If ChromeDriver version mismatches regularly break your CI, that's a recurring tax Playwright eliminates entirely.
  • Your WebDriverWait + ExpectedConditions utility library is sprawling and still producing flaky tests.
  • You need mobile emulation, network interception, or multi-context testing that Selenium can't provide cleanly.
  • Your team writes TypeScript and wants first-class types throughout.

Migration does not make sense when:

  • Your suite is in Java or Python and your team is not moving to TypeScript. Playwright has Java and Python bindings, but the ecosystem depth and community examples are weaker.
  • You test in browsers Playwright doesn't support (Internet Explorer, legacy Edge).
  • Your suite is stable, your CI is fast, and your team has no pressing pain.

For Cypress teams, the calculation is different. Cypress and Playwright solve the same problem at roughly the same level of abstraction. Migrate when:

  • You need Safari/WebKit coverage. Playwright's WebKit engine is the only way to get real browser rendering on Windows without a macOS machine.
  • You're hitting the parallelization paywall and need free sharding across CI runners.
  • You regularly write multi-tab or cross-domain tests that require Cypress workarounds.
  • You want API testing in the same framework, same test run.

Stay on Cypress when your suite is working and you hit none of these walls. Migrating 300 working Cypress tests to gain a feature you don't need is pure cost with no return.

A reasonable rule: if the annual engineering time lost to your current framework's pain exceeds two weeks of salary, migration pays for itself in year one.

The mental model shift: protocol, waiting, and execution

Understanding why Playwright behaves differently makes the rest of the migration faster.

Selenium communicates with browsers through the WebDriver protocol: HTTP requests from your test process to a browser driver process, which forwards commands to the browser. Every action is a round trip. This is why Selenium is slow and why waiting is explicit: the framework has no visibility into what the browser is doing between commands. Playwright uses Chrome DevTools Protocol (CDP) for Chromium, and analogous low-level protocols for Firefox and WebKit. The connection is a persistent WebSocket, not per-command HTTP. Playwright's process is tightly coupled to the browser and can observe browser state directly. This is what makes auto-waiting possible.

When you call await page.getByRole('button', { name: 'Submit' }).click(), Playwright doesn't click immediately. It polls until the element exists in the DOM, is visible, is not covered by another element, and is not disabled, then it clicks. The actionability check is built into every interaction. You almost never need explicit waits.

Cypress uses a different approach: it runs JavaScript inside the browser process itself (not as an external client). Its command queue model sequences operations without await because the queue handles ordering internally. This makes Cypress feel synchronous even though it isn't. Playwright drops this abstraction and uses standard async/await, which is more explicit, integrates with standard JavaScript tooling, and is easier to reason about when things go wrong.

The practical implication: when you migrate, delete your waiting utilities. Don't port them. If you find yourself adding explicit waits in Playwright to fix flakiness, that's a sign your locator or test structure has a deeper problem.

Locator mapping

Selenium's By class and Playwright's locator API overlap in capability but differ in philosophy. Playwright's locators are semantic by default. They encourage you to find elements the way a user would, by role and visible text, not by CSS implementation details.

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

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

// Playwright equivalent: semantic locator
await page.getByRole('button', { name: 'Submit' }).click();

// Playwright: test ID (when you own the markup)
await page.getByTestId('submit-btn').click();

// Playwright: CSS fallback (valid, but reach for this last)
await page.locator("button[data-testid='submit-btn']").click();

The mapping table for common patterns:

// By.id("username")
await page.locator('#username');
await page.getByLabel('Username');  // better if label exists

// 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');
// or semantically:
await page.getByRole('alert');

When you encounter XPath in Selenium tests, resist the urge to copy the XPath string into page.locator(). XPath works in Playwright, but it anchors your tests to implementation structure. Use the migration as an opportunity to replace brittle XPath with getByRole or getByLabel. Tests that rely on semantic locators survive refactoring far better.

Run npx playwright codegen https://your-app.com against your application. The code generator writes locators for every element you interact with and defaults to getByRole and getByLabel where possible. Use it to discover what semantic locators are available before manually writing mappings.

Page Object migration

Page Objects translate directly from Selenium to Playwright. The pattern is the same. The differences are: constructor receives Page instead of WebDriver, you await everything, and locators are defined as Playwright Locator objects rather than By descriptors.

Here is a Selenium POM class:

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

The Playwright equivalent:

// 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() ?? '';
  }
}

Notice what disappeared: all the WebDriverWait calls, the until.elementLocated wrappers, and the .sendKeys method (replaced by .fill). The Playwright version is shorter because the auto-waiting that was manual in Selenium is now implicit.

One structural improvement worth making during migration: define locators as class properties in the constructor rather than inline in methods. This makes locators easy to scan and update, and Playwright's Locator objects are lazy. They don't query the DOM until you call an action on them, so defining them in the constructor has no performance cost.

Migrating from Cypress

Cypress-to-Playwright migrations are shorter but require unlearning the command queue model.

Command chaining vs async/await:

// Cypress: no await, command queue
describe('Login', () => {
  it('logs in successfully', () => {
    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: standard async/await
import { test, expect } from '@playwright/test';

test('logs in successfully', 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');
});

Network interception:

// 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');
// No explicit wait needed. Auto-waiting handles it.

Fixtures and setup:

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

// Playwright: use fixtures for shared setup
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('admin dashboard loads', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.getByRole('heading')).toHaveText('Dashboard');
});

Playwright fixtures are more composable than Cypress's beforeEach pattern. You can stack fixtures, make them depend on each other, and scope them to a single test or an entire file. During migration, convert beforeEach login blocks to a storageState fixture. It serializes the browser's cookies and localStorage after one login and reuses them across tests without repeating the login UI flow.

Migration strategy: parallel running and the strangler fig

Don't migrate everything at once. That approach produces a multi-week period where nothing works and your CI has no green signal. Use the strangler fig pattern instead: run Playwright and your existing framework side by side, migrating one feature area at a time.

Step 1: Install Playwright alongside your existing framework.

npm init playwright@latest

Choose "TypeScript", put tests in playwright-tests/ (not tests/ if that's your Selenium/Cypress directory), and skip the GitHub Actions file for now.

Step 2: Set CI to run both suites. Your pipeline runs Selenium (or Cypress) and Playwright. Both must pass. This keeps your green signal intact while the migration proceeds. Step 3: Pick a starting module. Choose a feature area with clear page objects and stable tests, something like a login flow or a checkout process. Migrate that module completely: page objects, tests, test data. Step 4: Delete the old tests for that module. Once the Playwright version is green for two weeks, remove the Selenium/Cypress counterparts. Don't leave both running indefinitely. Duplicate tests double your CI time and create maintenance overhead. Step 5: Repeat module by module until the old framework has no tests left. Remove it from package.json and the CI pipeline.
Keep a migration tracker: a simple spreadsheet or Notion table with test file names, migration status (not started / in progress / done / deleted), and the engineer responsible. Without it, half-migrated state silently persists for months.

For large Selenium suites (1000+ tests), consider a split approach: use Playwright's built-in codegen to record new tests for high-value flows, and write a migration script to mechanically convert simple Selenium tests (click, fill, assert text) that follow predictable patterns. Mechanical conversion won't produce idiomatic Playwright, but it creates a working baseline you can clean up incrementally.

Common migration pitfalls

Hardcoded waits. The most common mistake when migrating Selenium tests is copying Thread.sleep() or await driver.sleep(2000) into Playwright. These waits are hiding real problems: elements that aren't actionable, animations that haven't completed, network requests that haven't resolved. In Playwright, page.waitForTimeout(2000) exists but should almost never appear in test code. Replace every hardcoded wait with an explicit assertion that the element you need is in the state you expect:

// Wrong: copying the Selenium habit
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();

// Right: wait for the specific condition
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();

Flaky selectors. Migrating a Selenium test that used By.xpath("//div[3]/button") with the XPath copied verbatim into page.locator() carries the brittleness with it. Any structural DOM change breaks it. Use the migration as a forcing function to replace fragile selectors with semantic ones. Test ordering assumptions. Selenium test suites frequently share state between tests: a test that creates a user, and the next test that logs in as that user. Playwright runs tests in parallel by default and across multiple workers, so shared state between tests causes random failures that are hard to reproduce. Each test needs to create its own data and clean up after itself, or use Playwright's storageState to reuse authentication without sharing mutable state.

// Wrong: depends on a previous test having created the user
test('user can update profile', async ({ page }) => {
  await page.goto('/profile'); // assumes login state from a previous test
  // ...
});

// Right: each test is self-contained
test('user can update profile', async ({ page, context }) => {
  await context.addCookies(/* auth cookies from storageState */);
  await page.goto('/profile');
  // ...
});

iframe mishandling. Selenium's driver.switchTo().frame() pattern has a direct Playwright equivalent, but it's different enough to cause confusion:

// 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 need to switch back. Playwright's frame locator is scoped automatically.

CI migration: updating your pipeline

Replacing Selenium Grid or Cypress Cloud with Playwright in CI is straightforward. Playwright installs browsers as part of its setup and runs without a separate driver process.

From Selenium Grid:

# Before: Selenium Grid with Docker
services:
  selenium-hub:
    image: selenium/hub:4
  chrome:
    image: selenium/node-chrome:4

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

# After: Playwright (no external services needed)
steps:
  - name: Install dependencies
    run: npm ci

  - name: Install Playwright browsers
    run: npx playwright install --with-deps chromium

  - name: Run Playwright tests
    run: npx playwright test

From Cypress Cloud with parallelization:

# Before: Cypress with paid Cloud parallelization
- name: Cypress run
  uses: cypress-io/github-action@v6
  with:
    record: true
    parallel: true
  env:
    CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

# After: Playwright with free built-in sharding
strategy:
  matrix:
    shard: [1, 2, 3, 4]

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

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

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

To merge shard reports into a single HTML report after all shards complete:

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

During the parallel-running phase of your migration, structure CI to run both suites but report failures separately. That way a flaky Selenium test doesn't block your Playwright migration progress, and you have a clean signal on which framework is causing which failures.

Don't remove Selenium Grid or Cypress Cloud from your pipeline until every test that was running in the old framework is either migrated to Playwright or intentionally deleted. Removing infrastructure first and migrating later is how teams end up with uncovered functionality.

FAQ

How long does migration actually take?

For a 500-test Selenium suite with a single dedicated engineer, expect 4–6 weeks for the migration work plus another 2 weeks of stabilization (fixing flakiness in the new framework). Cypress suites of the same size take 2–4 weeks because the locator patterns and JavaScript mental model are closer. Large suites (2000+ tests) with no team ownership of the tests can stretch to months. Plan for 20–30% longer than your initial estimate.

Do I need to rewrite every test or can I automate some of it?

You can automate the mechanical parts: find/replace cy.get( with page.locator(, convert cy.visit to await page.goto, wrap everything in async. That handles maybe 30% of the work and creates something that compiles. The remaining 70%, replacing flaky selectors with semantic ones, removing hardcoded waits, fixing test ordering issues, requires human judgment.

What about my existing Page Object classes?

Keep the pattern, replace the imports and constructor. The structural investment in POM is not wasted. See the before/after example in the Page Object migration section above. The refactor is mechanical for most methods.

Should I migrate to Playwright's component testing at the same time?

No. Migrate your E2E suite first. Playwright's component testing is a separate tool with a separate learning curve. Trying to migrate two things simultaneously slows both down.

What if some tests genuinely can't migrate?

Keep them in the old framework. Run them on a separate CI job. Don't let the perfect block the good. A 90% migrated suite running on Playwright is meaningfully better than a 0% migrated suite because you're waiting to port three edge-case tests.

What happens after migration?

Once your suite is running on Playwright, you're in a position to improve it: add network mocking to speed up tests that hit real APIs, introduce storageState to eliminate repeated login flows, enable parallel test execution with workers: 'auto', and add API test coverage using Playwright's request fixture. The migration is a floor, not a ceiling.

→ See also: Playwright in 2026: Why It Became the #1 Test Framework | Page Object Model in Playwright: From Messy to Maintainable | Handling Auth in Playwright with storageState (No Logging In Every Test) | Parallel Execution in Playwright: Workers, Shards, and Sharding for Speed