Selenium to Playwright Migration

If you have spent years building Selenium frameworks in Java and are now looking at Playwright, the hardest part is not learning Playwright — it is un-learning Selenium habits. Almost everything you do in Selenium has a direct Playwright equivalent, and in most cases the Playwright version is shorter because the framework does work you used to do manually.

This guide maps each major Selenium concept to its Playwright counterpart, side by side. It is written from the perspective of someone who ran both in real projects: I initially used Selenium on a dynamic web application, faced persistent flaky-test issues on dynamic pages, and migrated the critical flows — login, payment, and multi-user testing — to Playwright. Parallel execution improved regression speed significantly. This guide is the mapping I wish I had at the start.

Why teams migrate (and why some shouldn't)

The three Selenium pain points that drive most migrations:

Advertisement
  • Flaky tests due to manual waits. Every Selenium engineer has fought synchronization — implicit waits, explicit waits, fluent waits, and the occasional shameful Thread.sleep().
  • Modern JS frameworks (React, Angular) are difficult to handle. Single-page applications re-render the DOM constantly, and Selenium needs manual waiting logic to cope.
  • Parallel execution setup is complex. Selenium Grid or TestNG parallel configuration works, but it takes real setup effort.

Playwright addresses all three: auto-wait reduces flaky failures, SPAs and AJAX-heavy pages are handled dynamically, and parallel execution works out of the box using isolated browser contexts.

That said, Selenium is not "wrong." It is widely used in legacy projects, supports more enterprise setups, and if your suite is stable and your team is Java-native, migration is a cost you should justify — not a default. The rest of this guide assumes you have decided to move, or want to evaluate the move properly.

The big-picture comparison

Feature Selenium Playwright
Browser support Chrome, Firefox, IE, Edge Chromium, Firefox, WebKit
Language support Java, Python, C#, JavaScript TypeScript/JavaScript, Python, Java, C#
Auto-wait / synchronization Needs explicit waits (flaky sometimes) Built-in auto-wait (less flaky)
Single Page Applications (SPA) Limited support; manual waits needed Handles SPA dynamically and efficiently
Cross-browser testing Supported but slower Fast with isolated browser contexts
Installation / setup Needs WebDriver setup for each browser No WebDriver required; installs browsers automatically
Modern web features Limited handling of AJAX, shadow DOM, etc. Native support for AJAX, SPA, Web Components

(Suggested image: a two-column architecture diagram, Selenium on the left, Playwright on the right — see the architecture section below for what to draw.)

1. Architecture: what actually changes under the hood

Selenium. WebDriver architecture involves four layers: your test script, the WebDriver API, a browser driver (like ChromeDriver), and the browser itself. Think of it like sending a letter through a postman — you write the letter (test script), the post office (WebDriver API) takes it, the postman (ChromeDriver) delivers it, and your friend's house (the browser) acts on it. Communication happens over the JSON Wire Protocol, or the W3C WebDriver Protocol in newer versions. Every command is an HTTP round trip through the driver executable.

Playwright. There is no separate driver executable to download, match to your browser version, or set with System.setProperty(). Playwright installs its own browser binaries (npx playwright install) and talks to them directly over a persistent connection. The postman is gone — you are speaking to the browser without an intermediary.

What this means for your migration: all the driver-management code in your framework — System.setProperty("webdriver.chrome.driver", ...), WebDriverManager dependencies, driver-version-mismatch debugging — simply has no equivalent. Delete the concept, not just the code.

2. Setup and project structure

Selenium (Java): a Maven project — pom.xml with dependencies and plugins, TestNG for the runner, an Eclipse/IntelliJ project structure with src/main/java for pages and utilities and src/test/java for tests, and testng.xml controlling what runs.

Playwright (TypeScript): a Node project — npm init playwright@latest scaffolds everything, playwright.config.ts replaces both pom.xml-style configuration and testng.xml. Browser targets, parallel workers, retries, timeouts, reporters, and base URL all live in this one config file.

Selenium concept Playwright equivalent
pom.xml dependencies package.json
testng.xml suite file playwright.config.ts (projects section)
Maven Surefire plugin Playwright Test Runner (built in)
mvn test npx playwright test
TestNG @Test test('name', async ({ page }) => {...})
@BeforeMethod / @AfterMethod test.beforeEach / test.afterEach

3. Locators: from findElement to getByRole

Selenium. Locators are the way to identify elements: id, name, className, tagName, linkText, partialLinkText, CSS selector, and XPath — with XPath split into absolute and relative, and relative XPath being the safe choice because absolute XPath breaks the moment the DOM structure shifts. In practice, most Selenium projects live on CSS selectors and relative XPath, plus XPath axes for dependent elements.

// Selenium
WebElement loginBtn = driver.findElement(By.xpath("//button[text()='Login']"));
loginBtn.click();

Playwright. All your locator knowledge transfers — CSS and XPath both still work in page.locator(). But the recommended strategy is different: user-facing locators first. getByRole(), getByText(), getByLabel(), getByPlaceholder() find elements the way a user perceives them, which makes tests resilient to DOM refactoring in a way even good relative XPath is not.

// Playwright — same element, three levels of preference await page.getByRole('button', { name: 'Login' }).click(); // recommended await page.getByText('Login').click(); // good await page.locator("//button[text()='Login']").click(); // your XPath still works

Migration tip: do not mechanically convert every XPath. Port the locator intent. If your Selenium locator was //input[@id='username'], the Playwright version is page.getByLabel('Username') — not the same XPath wrapped in page.locator(). Keep XPath for the genuinely hard cases (no stable attributes, no accessible name), where it remains as valid in Playwright as it was in Selenium.

Also gone: the findElement vs findElements distinction (one element vs a list, with findElement throwing NoSuchElementException and findElements returning an empty list). In Playwright, page.locator() returns a lazy locator that can represent one or many elements — use .count(), .all(), or .first() when you need list behavior, and nothing is thrown at creation time because the element is not looked up until you act on it.

4. Waits and synchronization: the biggest mindset shift

This is the section that matters most, because synchronization is where Selenium suites bleed stability.

Selenium gives you a toolbox you must operate manually:

  • Implicit wait — a global timeout applied to every findElement.
  • Explicit waitWebDriverWait with ExpectedConditions, applied per element:
WebDriverWait wait = new WebDriverWait(driver, 10);
wait.until(ExpectedConditions.alertIsPresent());
  • Fluent wait — defines the maximum time to wait for a condition plus the frequency with which to check it before throwing ElementNotVisibleException. It checks for the element at regular intervals until it is found or the timeout happens — ideal when an element might load in 10 seconds or 20 seconds or more:
Wait wait = new FluentWait(driver)
    .withTimeout(Duration.ofSeconds(30))
    .pollingEvery(Duration.ofSeconds(5))
    .ignoring(Exception.class);
  • Thread.sleep() — the wait everyone uses and nobody admits to. It blocks unconditionally for the full duration, wastes time when the element is ready early, and still fails when the element is slow.

Playwright replaces the entire toolbox with auto-wait: it automatically waits for elements to be ready — visible, enabled, stable — before performing actions, without explicit waits. Like waiting for a lift door to fully open before entering. Before clicking a Login button, Playwright itself waits for it to be visible and enabled.

// This one line contains the wait. There is no second line.
await page.getByRole('button', { name: 'Login' }).click();

Migration mapping:

Selenium wait Playwright equivalent
Implicit wait Nothing — auto-wait covers it
ExpectedConditions.visibilityOf(...) Auto-wait on action, or await expect(locator).toBeVisible()
ExpectedConditions.elementToBeClickable(...) Auto-wait built into .click()
Fluent wait with polling Auto-wait + expect() polling (web-first assertions retry automatically)
Thread.sleep(5000) Delete it. If truly unavoidable: await page.waitForTimeout(5000) — treat it as the same code smell it was in Selenium
Wait for page load await page.waitForLoadState()

The honest caveat: auto-wait removes ~90% of your wait code, not 100%. Waiting for a backend-driven state change that has no UI signal still needs thought in Playwright — you'll reach for expect().toHaveText() polling or network waiting (Section 12) instead of FluentWait, but you will still have to reach for something.

5. Dropdowns: Select class → selectOption

Selenium. Dropdowns built with the <select> HTML tag are handled with the Select class from org.openqa.selenium.support.ui. You create a Select object by passing the WebElement returned by your locator, then use its methods — selectByVisibleText(), selectByValue(), selectByIndex(), getOptions(), isMultiple() to check whether it is multi-select, and deselectAll() for multi-selects.

Select dropdown = new Select(driver.findElement(By.id("country")));
dropdown.selectByVisibleText("India");

Playwright. No wrapper class. One method on the locator:

await page.locator('#country').selectOption({ label: 'India' });
// or by value:            selectOption('IN')
// or multiple values:     selectOption(['IN', 'US'])

Watch out for the same trap in both tools: custom dropdowns built with <div>/<ul> instead of <select> were never handleable by the Select class in Selenium, and selectOption won't handle them in Playwright either. Those remain click-the-trigger-then-click-the-option in both worlds — that part of your Selenium experience transfers unchanged.

6. Alerts: switchTo().alert() → the dialog event

Selenium. You switch the driver's focus to the alert, then act on it:

Alert alert = driver.switchTo().alert();
String alertText = alert.getText();
alert.accept();          // click OK
alert.dismiss();         // click Cancel
alert.sendKeys("text");  // type into a prompt

And because the alert may not be present yet, you wrap it in an explicit wait with ExpectedConditions.alertIsPresent().

Playwright. The model inverts: instead of reacting to an alert after it appears, you register a listener before the action that triggers it. Playwright handles alerts using the Dialog API — it listens for dialogs and lets you accept, dismiss, or provide input via the page.on('dialog') event:

page.on('dialog', async dialog => {
  console.log(dialog.message());
  await dialog.accept();        // or dialog.dismiss()
});
await page.getByRole('button', { name: 'Delete' }).click();

Migration trap: this ordering difference is the #1 source of confusion for Selenium engineers. In Selenium you click, then switch to the alert. In Playwright, if no handler is registered, dialogs are auto-dismissed — so a mechanically converted test will "pass" while silently cancelling every confirmation. Register the handler first, then perform the triggering action.

This also maps cleanly to the dynamic-alert problem from Selenium — alerts that don't always appear. In Selenium that meant try/catch around alertIsPresent(). In Playwright, the page.on('dialog') handler simply fires when a dialog appears and does nothing when it doesn't — the flakiness category disappears.

7. Frames: switchTo().frame() → frameLocator

Selenium. Frames are handled by switching context, three ways — by index (starting at 0), by name or ID, or by WebElement:

driver.switchTo().frame(0);              // by index
driver.switchTo().frame("iframe1");      // by name or ID
// ...interact...
driver.switchTo().defaultContent();      // back to the main page

Forgetting to switch back to defaultContent() is a classic Selenium bug — subsequent locators silently search the wrong document.

Playwright. No context switching. A frameLocator scopes queries into the frame, and every other locator continues to work against the main page:

const frame = page.frameLocator('#iframe1');
await frame.getByRole('textbox', { name: 'Card number' }).fill('4111...');
await page.getByRole('button', { name: 'Pay' }).click();  // main page — no switching back

The entire "which context am I in?" state management, including the switch-back-to-main-frame step, is deleted by the model itself.

8. Windows, tabs, and popups: window handles → contexts and pages

Selenium. New browser windows/tabs are handled with window handles: capture driver.getWindowHandle(), perform the action that opens the popup, loop through driver.getWindowHandles(), and driver.switchTo().window(handle) to the new one. For OS-level dialogs — windows-based authentication popups, native file dialogs — WebDriver cannot help directly, which is why Selenium projects reach for AutoIt or Robot class, or bypass basic-auth popups by embedding credentials in the URL (https://username:password@site.com).

Playwright.

const [popup] = await Promise.all([
  page.waitForEvent('popup'),
  page.getByRole('link', { name: 'Open report' }).click(),
]);
await popup.getByRole('heading').isVisible();  // both pages usable simultaneously

No handle iteration, and no switching — page and popup are both live objects. For HTTP basic authentication, Playwright supports credentials directly in the browser context (httpCredentials in config), replacing the AutoIt/URL-embedding workarounds for that case. (True OS-native dialogs remain out of reach for both tools — that limitation transfers.)

9. File upload: sendKeys → setInputFiles

Selenium. Locate the <input type="file"> element and use sendKeys() with the file path — and when there is no input element (a native OS dialog), fall back to Robot class or AutoIt.

driver.findElement(By.id("upload")).sendKeys("/path/to/file.pdf");

Playwright:

await page.locator('#upload').setInputFiles('/path/to/file.pdf');
// multiple files:
await page.locator('#upload').setInputFiles(['a.pdf', 'b.pdf']);

For the no-input-element case, Playwright has page.on('filechooser'), which intercepts the file chooser event — a real API where Selenium needed a third-party tool.

10. Actions class → built-in locator methods

Selenium. Mouse and keyboard composition lives in the Actions class, always ending with build() and perform()build() compiles the sequence of actions into a single composite action, perform() executes it:

Actions actions = new Actions(driver);
actions.contextClick(element).build().perform();            // right-click
actions.dragAndDrop(source, target).build().perform();      // drag and drop
actions.moveToElement(menu).build().perform();              // hover
element.sendKeys(Keys.ENTER);                                // keyboard
element.sendKeys(Keys.SHIFT, Keys.TAB);                      // key chord

Playwright. The common gestures are first-class locator methods — no builder object:

await locator.click({ button: 'right' });    // right-click
await source.dragTo(target);                  // drag and drop
await locator.hover();                        // hover
await locator.press('Enter');                 // keyboard
await locator.press('Shift+Tab');             // key chord

The distinction between dragAndDrop() (element to element) and dragAndDropBy() (element by x,y offset) maps to dragTo(target) vs mouse.move()/mouse.down()/mouse.up() for coordinate-based drags.

11. JavaScriptExecutor → page.evaluate

Selenium. JavascriptExecutor (reached by typecasting the driver) is the escape hatch: clicking elements that refuse click(), scrolling into view, reading hidden-element text, changing attribute values.

JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript("arguments[0].click();", element);
js.executeScript("window.scrollBy(0,500)");

Playwright:

await page.evaluate(() => window.scrollBy(0, 500));
await locator.evaluate(el => el.textContent);   // hidden element text

But notice how much less you need it. The two biggest Selenium reasons to reach for JSExecutor — force-clicking not-yet-ready elements and scrolling elements into view — are handled by auto-wait, which scrolls elements into view automatically before acting. In a migrated suite, page.evaluate usage should be a fraction of your old JSExecutor usage; if it isn't, you are porting workarounds you no longer need.

12. Assertions: verify/assert → web-first expect()

Selenium/TestNG. The classic distinction: assert stops test execution on failure (hard assertion), verify logs the failure and continues (soft assertion — TestNG's SoftAssert). And crucially, TestNG assertions check a value at the moment you read it — so an assertion on text that hasn't rendered yet fails even though the app is fine, which is why assertions in Selenium so often hide a wait problem.

Playwright uses the expect() assertion library, and the difference from traditional assertions is fundamental: web-first assertions retry until the condition is met or the timeout expires.

await expect(page.getByRole('alert')).toHaveText('Payment successful');
// retries automatically — this is an assertion AND a wait in one line
Selenium/TestNG Playwright
Assert.assertEquals(el.getText(), "...") await expect(locator).toHaveText('...')
Assert.assertTrue(el.isDisplayed()) await expect(locator).toBeVisible()
Checkbox/radio selected check (isSelected()) await expect(locator).toBeChecked()
SoftAssert (verify) expect.soft()
Assert (hard) expect() (default)

Migration payoff: a large share of your ExpectedConditions explicit waits existed only to make assertions safe. In Playwright the assertion is the wait, so those waits and their assertions collapse into single lines.

13. Parallel execution: Grid/TestNG parallel → workers

Selenium. Two mechanisms, both requiring setup. Selenium Grid distributes tests across machines (hub/nodes) for cross-browser parallel runs. TestNG parallel execution is configured in testng.xml with parallel="methods|classes|tests" and thread-count, and brings real advantages (faster feedback, better coverage across environments) alongside real disadvantages — shared-state hazards, thread-safety requirements on your driver management (ThreadLocal WebDriver), and harder debugging.

Playwright. Parallelism is the default. Tests run in parallel across worker processes, each fully isolated with its own browser context:

// playwright.config.ts
export default defineConfig({
  workers: 4,          // or a percentage: '50%'
  fullyParallel: true,
});

Because each worker gets an isolated context (cookies, storage, cache all separate), the ThreadLocal-driver discipline and shared-state hazards that made TestNG parallel dangerous mostly disappear. In my own migration this was the single biggest measurable win — parallel execution improved regression speed significantly, without the Grid infrastructure.

Cross-browser coverage moves from Grid nodes to config projects — chromium, firefox, and webkit entries in playwright.config.ts, all running in the same parallel pool.

14. Page Object Model and Page Factory → POM and fixtures

Selenium. POM is the standard design pattern, and Page Factory is its Selenium-specific enhancement — @FindBy annotations with PageFactory.initElements() for lazy element initialization.

Playwright. POM transfers almost unchanged as plain classes — no annotations, no init step, because locators are already lazy by nature (they resolve when acted on, which is exactly what Page Factory was simulating):

export class LoginPage {
  constructor(private page: Page) {}
  usernameInput = () => this.page.getByLabel('Username');
  loginButton  = () => this.page.getByRole('button', { name: 'Login' });

  async login(user: string, pass: string) {
    await this.usernameInput().fill(user);
    await this.page.getByLabel('Password').fill(pass);
    await this.loginButton().click();
  }
}

What replaces the rest of your framework plumbing — the @BeforeMethod driver setup, the utility/base classes — is fixtures. A fixture is dependency injection for tests: it prepares what a test needs (a page object, a logged-in state, test data) and hands it to the test as a parameter:

export const test = base.extend<{ loginPage: LoginPage }>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
});
// in the test file:
test('valid login', async ({ loginPage }) => {
  await loginPage.login('naveed', 'secret');
});

The framework layers stay recognizable — pages, tests, utilities, config, test data, reports — the same layered structure you built in Selenium; only the wiring between the layers changes from inheritance-and-annotations to fixtures.

15. What you gain that has no Selenium equivalent

These are not migrations — they are new capabilities, and they are the strongest arguments for the move:

Network interception. Playwright can intercept, modify, mock, or block network calls with page.route(). Mock a payment API's failure response, block analytics/image requests to speed up tests, or wait for a specific API response before asserting. Selenium simply cannot see the network layer (without external proxies like BrowserMob).

Storage state (login reuse). storageState saves cookies and localStorage after one login and injects them into every subsequent test's context — so the entire suite skips the login UI. The Selenium equivalent (manually managing cookies with driver.manage().getCookies()) was fragile enough that most teams just logged in every test.

Trace viewer. Record a trace (trace: 'on-first-retry') and get a full replay of a failed test — DOM snapshots at every step, network calls, console logs, screenshots — viewable with npx playwright show-trace. This replaces the screenshot-on-failure + log-archaeology debugging workflow, and it is the feature Selenium engineers say they can't go back from.

16. What does NOT transfer (the honest section)

  • Your language. If your entire ecosystem is Java, note that Playwright's Java bindings exist but the ecosystem's center of gravity — the test runner, fixtures, trace viewer integration, community answers — is TypeScript/JavaScript. A Selenium-Java to Playwright-TS migration is also a language migration; budget for it.
  • Apache POI / Excel-driven data. Your Excel reading utilities (Apache POI) don't port; the Node world uses JSON/CSV fixtures or libraries like xlsx. Data-driven structure survives, the utility code doesn't.
  • TestNG-specific features. dependsOnMethods, priorities, and complex testng.xml group orchestration have partial equivalents (test.describe.serial, tags) but Playwright's philosophy is independent, order-free tests — suites that leaned on execution ordering need redesign, not translation.
  • Legacy browser coverage. Selenium's IE support has no Playwright equivalent. If you still test IE, keep a Selenium suite for it.
  • AutoIt/Robot flows. Mostly you won't need them (file chooser, basic auth are native now) — but genuinely OS-native automation is out of scope for both tools.

17. A migration strategy that actually works

Do not rewrite the whole suite. This is the sequence I followed and would recommend:

  1. Run both in parallel; migrate critical flows first. I started with login, payment, and multi-user flows — the tests where Selenium flakiness hurt most and Playwright's stability pays back fastest. The old Selenium suite keeps running for everything else.
  2. Port intent, not code. Every Thread.sleep, every JSExecutor force-click, every ExpectedConditions block — ask "what problem was this solving?" before writing its Playwright version. Most of them were solving problems Playwright doesn't have.
  3. Adopt the new capabilities during migration, not after. Set up storageState login reuse and trace-on-retry from day one; retrofitting them later means touching every test twice.
  4. Rebuild locators to the recommended strategy. Migration is your one chance to move from XPath-everywhere to getByRole-first without it being a separate refactoring project.
  5. Delete the Selenium test only after its Playwright twin has been stable for a full regression cycle. Stability, not compilation, is the definition of "migrated."
  6. Measure and report the wins. Track regression suite duration and flaky-test rate before and after — in my case the parallel-execution speedup was the number that justified the effort to stakeholders.

Quick-reference cheat sheet

Task Selenium (Java) Playwright (TS)
Open URL driver.get(url) await page.goto(url)
Find element driver.findElement(By...) page.locator() / getByRole()
Click element.click() await locator.click()
Type text element.sendKeys("...") await locator.fill('...')
Clear textbox element.clear() await locator.clear() (or fill(''))
Get text element.getText() await locator.textContent()
Get attribute element.getAttribute("href") await locator.getAttribute('href')
Current URL driver.getCurrentUrl() page.url()
Back / forward navigate().back() / forward() page.goBack() / page.goForward()
Maximize window manage().window().maximize() viewport in config (no maximize concept)
Screenshot TakesScreenshot typecast await page.screenshot({ path })
Dropdown new Select(el).selectByVisibleText() await locator.selectOption({ label })
Alert driver.switchTo().alert().accept() page.on('dialog', d => d.accept())
Frame driver.switchTo().frame("id") page.frameLocator('#id')
New window window handles + switchTo().window() page.waitForEvent('popup')
Upload input.sendKeys(filePath) await locator.setInputFiles(path)
Hover Actions.moveToElement().perform() await locator.hover()
Drag & drop Actions.dragAndDrop().perform() await source.dragTo(target)
Execute JS ((JavascriptExecutor) driver).executeScript() await page.evaluate()
Explicit wait WebDriverWait + ExpectedConditions auto-wait / await expect(locator)...
Parallel Grid / testng.xml parallel workers in config (default)
Run tests mvn test npx playwrig

Frequently Asked Questions

Is Playwright better than Selenium?

For modern SPAs (React, Angular) and teams starting fresh, Playwright's auto-wait, built-in parallelism, and debugging tools make it the stronger default. Selenium remains the right choice for legacy browser coverage, Java-centric enterprises, and stable existing suites where migration cost outweighs benefit.

Can I convert Selenium tests to Playwright automatically?

No reliable automated converter exists, and mechanical conversion misses the point — most Selenium code (waits, driver setup, JSExecutor workarounds) shouldn't be translated at all, because Playwright eliminates the problems it solved. Port test intent, not code.

Do I have to learn TypeScript to use Playwright?

Playwright supports Java, Python, and C# as well, so no. But the test runner, fixtures, and richest community support are strongest in TypeScript/JavaScript, so most migrating teams adopt TS.

How long does a Selenium to Playwright migration take?

Migrate incrementally: critical flows first (days to weeks), then the long tail while the Selenium suite keeps running. A full cutover for a mid-sized suite typically spans one to three regression cycles — the gating factor is stabilization, not rewriting.

Does Playwright need explicit waits like Selenium?

Almost never. Auto-wait handles element readiness before every action, and web-first expect() assertions retry automatically. The rare remaining cases use waitForLoadState, waitForResponse, or assertion polling — Thread.sleep-style fixed pauses are as much an anti-pattern in Playwright as they were in Selenium.