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+ExpectedConditionsutility 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.
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.
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');
});// 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.// 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@latestChoose "TypeScript", put tests in playwright-tests/ (not tests/ if that's your Selenium/Cypress directory), and skip the GitHub Actions file for now.
package.json and the CI pipeline.
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 copyingThread.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();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');
// ...
});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# 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-reportsDuring 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.
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.
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.