When to use: When converting a Selenium/WebDriver test suite to Playwright. Covers Java/Python/C# Selenium idioms mapped to TypeScript and JavaScript Playwright equivalents. Prerequisites: core/locators.md, core/assertions-and-waiting.md
Before translating any code, internalize these six changes. They are not syntax swaps -- they are fundamental differences in how Playwright works.
Selenium requires WebDriverWait and ExpectedConditions on nearly every interaction. Playwright auto-waits on every action (click, fill, check, selectOption) and every web-first assertion (expect(locator).toBeVisible()). Delete all your waits. They are not needed.
Selenium: WebDriverWait + ExpectedConditions.visibilityOfElementLocated(...)
Playwright: nothing — auto-waiting is built into every action and assertion
Selenium sends every command over the WebDriver (W3C) HTTP protocol -- each click, each find, each assertion is a round-trip HTTP request. Playwright communicates over CDP (Chromium), the DevTools protocol (Firefox), or native WebKit protocol. This is a persistent bidirectional connection, not request-response. Tests are faster by default.
Selenium requires matching browser drivers (chromedriver, geckodriver) to browser versions. Version mismatches are a constant source of CI failures. Playwright bundles browser binaries. One command installs everything:
npx playwright installNo WebDriverManager. No chromedriver path. No version matrix.
Selenium has implicit waits (global, apply to all findElement calls), explicit waits (WebDriverWait), and Thread.sleep() / time.sleep(). Teams mix these, creating unpredictable timing behavior. Playwright has one mechanism: auto-waiting. Every action waits for the element to be actionable. Every web-first assertion retries until timeout. There is nothing to configure.
In Selenium, findElement() immediately queries the DOM and returns a WebElement reference. If the DOM changes, that reference is stale and throws StaleElementReferenceException. In Playwright, page.locator() returns a lazy locator that re-queries the DOM on every action. There is no stale element. There is no StaleElementReferenceException. Ever.
Selenium is a browser automation library, not a test framework. You need JUnit, TestNG, pytest, or Mocha on top. Playwright Test is a full test runner with parallel execution, retries, fixtures, HTML reports, trace viewer, and UI mode. No assembly required.
Every Selenium API and its direct Playwright equivalent. The "Notes" column calls out behavior differences.
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.get(url) |
await page.goto(url) |
Playwright waits for load event by default; configurable via waitUntil |
driver.navigate().to(url) |
await page.goto(url) |
Same as above |
driver.navigate().back() |
await page.goBack() |
Waits for navigation to complete |
driver.navigate().forward() |
await page.goForward() |
Waits for navigation to complete |
driver.navigate().refresh() |
await page.reload() |
Waits for load event |
driver.getCurrentUrl() |
page.url() |
Synchronous in Playwright -- no await needed |
driver.getTitle() |
await page.title() |
Or use await expect(page).toHaveTitle('...') for assertion |
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.findElement(By.id("x")) |
page.locator('#x') or page.getByTestId('x') |
Prefer getByRole() or getByTestId() over ID selectors |
driver.findElement(By.css("x")) |
page.locator('x') |
CSS selectors work, but prefer semantic locators |
driver.findElement(By.xpath("x")) |
page.locator('xpath=x') |
Works but avoid XPath; use getByRole(), getByLabel(), getByText() |
driver.findElement(By.name("x")) |
page.locator('[name="x"]') |
Or page.getByLabel() if the field has a label |
driver.findElement(By.linkText("x")) |
page.getByRole('link', { name: 'x' }) |
Role-based is more resilient |
driver.findElement(By.partialLinkText("x")) |
page.getByRole('link', { name: /x/ }) |
Regex for partial match |
driver.findElement(By.className("x")) |
page.locator('.x') |
Class selectors are fragile; prefer semantic locators |
driver.findElement(By.tagName("x")) |
page.locator('x') |
Rarely useful alone; combine with role or text |
driver.findElements(By.css("x")) |
await page.locator('x').all() |
Returns array of locators; or use toHaveCount() to assert count |
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
element.click() |
await locator.click() |
Auto-waits for element to be visible, stable, enabled, and unobscured |
element.sendKeys("text") |
await locator.fill("text") |
Behavior change: fill() clears existing text first, then sets the value. Use pressSequentially() to type character by character. |
element.sendKeys(Keys.ENTER) |
await locator.press('Enter') |
Or await page.keyboard.press('Enter') |
element.clear() |
await locator.clear() |
Or await locator.fill('') |
element.submit() |
await locator.press('Enter') |
No direct equivalent; click the submit button or press Enter |
new Select(element).selectByVisibleText("x") |
await locator.selectOption({ label: 'x' }) |
Also supports { value: 'x' } and { index: 0 } |
element.isDisplayed() |
await expect(locator).toBeVisible() |
Use assertion form -- it auto-retries. locator.isVisible() does not retry. |
element.isEnabled() |
await expect(locator).toBeEnabled() |
Use assertion form for reliability |
element.isSelected() |
await expect(locator).toBeChecked() |
For checkboxes and radio buttons |
element.getText() |
await locator.textContent() |
Or prefer await expect(locator).toHaveText('...') which auto-retries |
element.getAttribute("x") |
await locator.getAttribute('x') |
Or await expect(locator).toHaveAttribute('x', 'value') |
element.getCssValue("x") |
await expect(locator).toHaveCSS('x', 'value') |
Use assertion form; computed values only |
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
WebDriverWait(driver, 10).until(...) |
Not needed | Playwright auto-waits on all actions and assertions |
ExpectedConditions.visibilityOfElementLocated(...) |
await expect(locator).toBeVisible() |
Built into every action; use assertion only when you need to verify visibility explicitly |
ExpectedConditions.elementToBeClickable(...) |
Not needed | click() auto-waits for clickability |
ExpectedConditions.presenceOfElementLocated(...) |
await expect(locator).toBeAttached() |
Rarely needed; most actions wait for attachment automatically |
ExpectedConditions.invisibilityOfElementLocated(...) |
await expect(locator).not.toBeVisible() |
Auto-retries until element disappears |
ExpectedConditions.textToBePresentInElement(...) |
await expect(locator).toHaveText('...') |
Auto-retries until text matches |
ExpectedConditions.titleIs("x") |
await expect(page).toHaveTitle('x') |
Auto-retries |
ExpectedConditions.urlContains("x") |
await expect(page).toHaveURL(/x/) |
Or await page.waitForURL('**/x') |
ExpectedConditions.alertIsPresent() |
page.on('dialog', ...) or page.waitForEvent('dialog') |
Register handler before the action that triggers the dialog |
Thread.sleep(5000) / time.sleep(5) |
Never | Delete it. Use auto-waiting assertions instead. |
driver.manage().timeouts().implicitlyWait(10) |
Not needed | No implicit waits in Playwright -- auto-waiting handles everything |
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.switchTo().frame("name") |
page.frameLocator('iframe[name="name"]') |
No context switching; chain locators directly into the frame |
driver.switchTo().frame(element) |
page.frameLocator('iframe#id') |
Target the iframe by any CSS selector |
driver.switchTo().defaultContent() |
Not needed | No frame switching in Playwright; each frameLocator is scoped |
driver.switchTo().parentFrame() |
Not needed | No frame switching to undo |
driver.switchTo().window(handle) |
context.pages() |
Access all pages in the context by index |
driver.getWindowHandle() |
Not needed | Use page references directly |
driver.getWindowHandles() |
context.pages() |
Returns array of all open pages |
| New window/tab opened by click | page.waitForEvent('popup') |
Register before the click; returns the new Page object |
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
driver.manage().window().setSize(w, h) |
await page.setViewportSize({ width: w, height: h }) |
Sets the viewport, not the OS window |
driver.manage().window().maximize() |
Configure in playwright.config use.viewport |
Or pass --headed with large viewport |
driver.manage().addCookie(cookie) |
await context.addCookies([cookie]) |
Takes an array; operates on the browser context |
driver.manage().getCookieNamed("x") |
await context.cookies() then filter |
Returns all cookies; filter in JS |
driver.manage().deleteAllCookies() |
await context.clearCookies() |
Clears all cookies in the context |
driver.executeScript("return ...") |
await page.evaluate(() => { ... }) |
Full access to browser JS context; supports return values |
driver.executeAsyncScript(...) |
await page.evaluate(async () => { ... }) |
evaluate supports async functions natively |
driver.getScreenshotAs(OutputType.FILE) |
await page.screenshot({ path: 'shot.png' }) |
Also supports fullPage: true, element screenshots via locator.screenshot() |
driver.quit() |
Handled automatically | Playwright Test manages browser lifecycle. No manual cleanup. |
| Selenium WebDriver | Playwright | Notes |
|---|---|---|
new Actions(driver).moveToElement(el).perform() |
await locator.hover() |
Single method, auto-waits |
new Actions(driver).doubleClick(el).perform() |
await locator.dblclick() |
Single method, auto-waits |
new Actions(driver).contextClick(el).perform() |
await locator.click({ button: 'right' }) |
Right-click option |
new Actions(driver).dragAndDrop(src, tgt).perform() |
await source.dragTo(target) |
Both are locators |
new Actions(driver).keyDown(Keys.SHIFT).click(el).keyUp(Keys.SHIFT).perform() |
await locator.click({ modifiers: ['Shift'] }) |
Modifier keys as option |
new Actions(driver).sendKeys(Keys.chord(Keys.CONTROL, "a")).perform() |
await page.keyboard.press('Control+a') |
Keyboard API for global shortcuts |
new Actions(driver).moveByOffset(x, y).perform() |
await page.mouse.move(x, y) |
Raw mouse API for canvas/map interactions |
The most common Selenium test. Notice the complete absence of explicit waits in the Playwright version.
Selenium (Java)
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.junit.jupiter.api.*;
public class LoginTest {
WebDriver driver;
@BeforeEach
void setUp() {
System.setProperty("webdriver.chrome.driver", "/path/to/chromedriver");
driver = new ChromeDriver();
driver.manage().window().maximize();
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
}
@AfterEach
void tearDown() {
if (driver != null) driver.quit();
}
@Test
void userCanLogIn() {
driver.get("https://myapp.com/login");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement emailField = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.id("email"))
);
emailField.clear();
emailField.sendKeys("user@example.com");
WebElement passwordField = driver.findElement(By.id("password"));
passwordField.clear();
passwordField.sendKeys("s3cure!Pass");
WebElement loginButton = wait.until(
ExpectedConditions.elementToBeClickable(By.cssSelector("button[type='submit']"))
);
loginButton.click();
wait.until(ExpectedConditions.urlContains("/dashboard"));
WebElement heading = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.tagName("h1"))
);
Assertions.assertEquals("Dashboard", heading.getText());
}
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
});What changed: 45 lines of Java with explicit waits, driver management, and element references became 9 lines of Playwright. No setup, no teardown, no waits, no driver path. The fill() call replaces clear() + sendKeys(). Semantic locators (getByLabel, getByRole) replace brittle By.id and By.cssSelector.
Demonstrates replacing findElements(), explicit waits for result count, and text assertions.
Selenium (Java)
@Test
void searchReturnsResults() {
driver.get("https://myapp.com/products");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement searchBox = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.cssSelector("input[placeholder='Search products']"))
);
searchBox.clear();
searchBox.sendKeys("wireless headphones");
searchBox.sendKeys(Keys.ENTER);
wait.until(ExpectedConditions.numberOfElementsToBeMoreThan(
By.cssSelector(".product-card"), 0
));
List<WebElement> results = driver.findElements(By.cssSelector(".product-card"));
Assertions.assertTrue(results.size() >= 3);
String firstTitle = results.get(0).findElement(By.cssSelector(".product-title")).getText();
Assertions.assertTrue(firstTitle.toLowerCase().contains("wireless"));
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('search returns results', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('wireless headphones');
await page.getByPlaceholder('Search products').press('Enter');
const results = page.getByTestId('product-card');
await expect(results).toHaveCount(3, { timeout: 10_000 });
await expect(results.first().getByTestId('product-title')).toContainText(/wireless/i);
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('search returns results', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('wireless headphones');
await page.getByPlaceholder('Search products').press('Enter');
const results = page.getByTestId('product-card');
await expect(results).toHaveCount(3, { timeout: 10_000 });
await expect(results.first().getByTestId('product-title')).toContainText(/wireless/i);
});What changed: No WebDriverWait for element visibility. No clear() before sendKeys(). No findElements() returning a stale list. The Playwright toHaveCount() auto-retries until the results appear. The regex assertion on toContainText replaces manual getText() + toLowerCase() + contains().
Selenium's frame switching is stateful and error-prone. Playwright's frameLocator is scoped and stateless.
Selenium (Java)
@Test
void fillPaymentFormInIframe() {
driver.get("https://myapp.com/checkout");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Switch into the payment iframe
wait.until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(
By.cssSelector("iframe#payment-frame")
));
// Now inside the iframe context
WebElement cardNumber = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.id("card-number"))
);
cardNumber.sendKeys("4242424242424242");
driver.findElement(By.id("expiry")).sendKeys("12/28");
driver.findElement(By.id("cvc")).sendKeys("123");
// Switch back to main content before interacting with the page
driver.switchTo().defaultContent();
driver.findElement(By.id("place-order")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector(".confirmation-message")
));
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('fill payment form in iframe', async ({ page }) => {
await page.goto('/checkout');
// No switching — just scope into the frame
const paymentFrame = page.frameLocator('#payment-frame');
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiry').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
// No switching back — main page locators still work
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('fill payment form in iframe', async ({ page }) => {
await page.goto('/checkout');
const paymentFrame = page.frameLocator('#payment-frame');
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiry').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});What changed: No switchTo().frame(). No switchTo().defaultContent(). No risk of forgetting to switch back. Playwright's frameLocator scopes into the iframe without changing the driver's global state. You can interact with the main page and the iframe in any order.
Selenium window handle management is notoriously fragile. Playwright makes it declarative.
Selenium (Java)
@Test
void handlePopupWindow() {
driver.get("https://myapp.com/settings");
String originalWindow = driver.getWindowHandle();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
driver.findElement(By.linkText("Connect OAuth Provider")).click();
// Wait for new window to appear
wait.until(ExpectedConditions.numberOfWindowsToBe(2));
// Find and switch to the new window
for (String handle : driver.getWindowHandles()) {
if (!handle.equals(originalWindow)) {
driver.switchTo().window(handle);
break;
}
}
// Interact with the popup
wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("authorize-btn")));
driver.findElement(By.id("authorize-btn")).click();
// Wait for popup to close and switch back
wait.until(ExpectedConditions.numberOfWindowsToBe(1));
driver.switchTo().window(originalWindow);
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector(".connection-status.success")
));
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('handle popup window', async ({ page }) => {
await page.goto('/settings');
// Register listener BEFORE the click that opens the popup
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Connect OAuth Provider' }).click();
const popup = await popupPromise;
// Interact with the popup — it's just another Page object
await popup.getByRole('button', { name: 'Authorize' }).click();
// Popup closes automatically; verify result on original page
await expect(page.getByText('Connected successfully')).toBeVisible();
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('handle popup window', async ({ page }) => {
await page.goto('/settings');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Connect OAuth Provider' }).click();
const popup = await popupPromise;
await popup.getByRole('button', { name: 'Authorize' }).click();
await expect(page.getByText('Connected successfully')).toBeVisible();
});What changed: No window handle iteration. No switchTo(). No tracking original vs new window. The popup is a Page object you interact with directly. When it closes, you just continue using the original page. The waitForEvent('popup') pattern is declarative and race-condition-free.
Selenium's Actions class requires chaining and perform(). Playwright has direct methods.
Selenium (Java)
@Test
void dragAndDropTask() {
driver.get("https://myapp.com/kanban");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement sourceCard = wait.until(
ExpectedConditions.visibilityOfElementLocated(
By.xpath("//div[@class='card' and contains(text(),'Fix login bug')]")
)
);
WebElement targetColumn = driver.findElement(
By.xpath("//div[@class='column' and .//h2[text()='Done']]")
);
Actions actions = new Actions(driver);
actions.dragAndDrop(sourceCard, targetColumn).perform();
// Verify the card moved
WebElement doneColumn = driver.findElement(
By.xpath("//div[@class='column' and .//h2[text()='Done']]")
);
WebElement movedCard = doneColumn.findElement(
By.xpath(".//div[@class='card' and contains(text(),'Fix login bug')]")
);
Assertions.assertTrue(movedCard.isDisplayed());
}Playwright (TypeScript)
import { test, expect } from '@playwright/test';
test('drag and drop task to done column', async ({ page }) => {
await page.goto('/kanban');
const card = page.getByText('Fix login bug');
const doneColumn = page.getByRole('heading', { name: 'Done' }).locator('..');
await card.dragTo(doneColumn);
// Verify card is now inside the Done column
await expect(doneColumn.getByText('Fix login bug')).toBeVisible();
});Playwright (JavaScript)
const { test, expect } = require('@playwright/test');
test('drag and drop task to done column', async ({ page }) => {
await page.goto('/kanban');
const card = page.getByText('Fix login bug');
const doneColumn = page.getByRole('heading', { name: 'Done' }).locator('..');
await card.dragTo(doneColumn);
await expect(doneColumn.getByText('Fix login bug')).toBeVisible();
});What changed: No Actions class. No perform(). No XPath for finding elements. dragTo() is a single method call on a locator. The verification uses scoped locators instead of XPath ancestor traversal.
A practical, ordered checklist for converting a Selenium suite to Playwright.
Do not rip out Selenium on day one. Run both in parallel.
# Install Playwright in your existing project
npm init playwright@latest
# This creates:
# - playwright.config.ts (or .js)
# - tests/ directory
# - package.json dependenciesConfigure baseURL in playwright.config to match your Selenium target:
TypeScript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests-playwright', // separate from Selenium tests
use: {
baseURL: 'https://staging.myapp.com',
trace: 'on-first-retry',
},
retries: process.env.CI ? 2 : 0,
});JavaScript
// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests-playwright',
use: {
baseURL: 'https://staging.myapp.com',
trace: 'on-first-retry',
},
retries: process.env.CI ? 2 : 0,
});If you have Selenium page objects, convert them to Playwright page objects. The pattern is similar but simpler.
Selenium Page Object (Java)
public class LoginPage {
private WebDriver driver;
private WebDriverWait wait;
private By emailField = By.id("email");
private By passwordField = By.id("password");
private By loginButton = By.cssSelector("button[type='submit']");
private By errorMessage = By.cssSelector(".error-message");
public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public void login(String email, String password) {
wait.until(ExpectedConditions.visibilityOfElementLocated(emailField));
driver.findElement(emailField).clear();
driver.findElement(emailField).sendKeys(email);
driver.findElement(passwordField).clear();
driver.findElement(passwordField).sendKeys(password);
driver.findElement(loginButton).click();
}
public String getErrorMessage() {
return wait.until(
ExpectedConditions.visibilityOfElementLocated(errorMessage)
).getText();
}
}Playwright Page Object (TypeScript)
// page-objects/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
private readonly emailField: Locator;
private readonly passwordField: Locator;
private readonly loginButton: Locator;
private readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.emailField = page.getByLabel('Email');
this.passwordField = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.getByRole('alert');
}
async login(email: string, password: string) {
await this.emailField.fill(email);
await this.passwordField.fill(password);
await this.loginButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toHaveText(message);
}
}Playwright Page Object (JavaScript)
// page-objects/login-page.js
const { expect } = require('@playwright/test');
class LoginPage {
constructor(page) {
this.page = page;
this.emailField = page.getByLabel('Email');
this.passwordField = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.getByRole('alert');
}
async login(email, password) {
await this.emailField.fill(email);
await this.passwordField.fill(password);
await this.loginButton.click();
}
async expectError(message) {
await expect(this.errorMessage).toHaveText(message);
}
}
module.exports = { LoginPage };Key differences in page objects:
- Locators are defined in the constructor, not as
Byobjects -- they are lazy and never go stale - No
WebDriverWaitanywhere - No
clear()beforefill() - Assertions can live inside the page object (
expectError) because they auto-retry
Start with your most valuable and most flaky tests. Convert them in order:
- Smoke tests -- highest value, run on every deploy
- Flaky tests -- Playwright's auto-waiting eliminates most flakiness
- Slow tests -- Playwright's parallelism and faster protocol make these faster
- Everything else -- bulk conversion of the remaining suite
For each test, apply the API mapping table above. The mechanical translation is:
- Remove all
WebDriverWaitandExpectedConditions - Replace
findElement(By.xxx)with semantic locators (getByRole,getByLabel,getByText) - Replace
sendKeyswithfill(orpressSequentiallyfor character-by-character input) - Replace
Assert/assertEqualswithexpect(locator).toHaveText()/toBeVisible()/ etc. - Remove
setUpandtearDown-- Playwright Test handles browser lifecycle - Remove
Thread.sleep()/time.sleep()entirely
| Selenium Infrastructure | Playwright Equivalent |
|---|---|
| Selenium Grid / Hub | npx playwright test --shard=1/4 (built-in sharding) |
| BrowserStack / SauceLabs | Often unnecessary; Playwright runs 3 browsers locally. Use for Safari on CI if needed. |
| WebDriverManager | npx playwright install (one command, all browsers) |
| TestNG XML suites | playwright.config projects array |
| JUnit @Tag / pytest marks | test.describe() grouping + --grep filtering |
| Allure / ExtentReports | Built-in HTML reporter (npx playwright show-report) + trace viewer |
| Screenshot on failure | Built-in: use: { screenshot: 'only-on-failure' } |
| Video recording | Built-in: use: { video: 'on-first-retry' } |
Once all tests pass in Playwright and have run green in CI for at least two weeks:
- Delete Selenium test files
- Remove Selenium dependencies (
selenium-webdriver,chromedriver,geckodriver) - Remove Selenium Grid infrastructure
- Update CI pipelines to run only Playwright
- Remove
setUp/tearDownboilerplate classes
In Selenium, driver.findElement(By.id("missing")) throws NoSuchElementException immediately. In Playwright, page.locator('#missing') returns a locator object without querying the DOM. It only throws when you perform an action on it and the element does not appear within the timeout.
// This does NOT throw — locators are lazy
const missing = page.locator('#does-not-exist');
// This throws after timeout — because the action waits and the element never appears
await missing.click(); // TimeoutError after actionTimeoutImpact: If your Selenium tests use try-catch around findElement to check element existence, replace with assertions:
// Selenium pattern (do not replicate)
// try { driver.findElement(By.id("error")); fail(); } catch (NoSuchElementException e) { /* expected */ }
// Playwright equivalent
await expect(page.locator('#error')).not.toBeVisible();Selenium's sendKeys("text") appends to the existing value. If the field already contains "hello" and you sendKeys("world"), you get "helloworld". Playwright's fill("text") clears the field first, then sets the value. You get "text".
// Selenium behavior: appends
// element.sendKeys("world"); // field: "helloworld"
// Playwright behavior: replaces
await locator.fill('world'); // field: "world"
// To replicate Selenium's append behavior:
await locator.pressSequentially('world'); // types each character, appending to existing valueIn Selenium, storing a WebElement reference and using it after the DOM changes throws StaleElementReferenceException. This is the single most common source of Selenium test flakiness.
// Selenium — this can throw StaleElementReferenceException
WebElement button = driver.findElement(By.id("submit"));
// ... some action causes the DOM to re-render ...
button.click(); // BOOM — StaleElementReferenceExceptionIn Playwright, locators re-query the DOM on every action. Store locators freely.
// Playwright — this always works
const button = page.getByRole('button', { name: 'Submit' });
// ... some action causes the DOM to re-render ...
await button.click(); // Works — re-queries the DOM automaticallySelenium has a single driver instance with global state: the current frame, the current window, implicit wait timeout. Calling switchTo().frame() changes state for all subsequent calls. Forgetting to switchTo().defaultContent() causes every following findElement to fail.
Playwright has no global state. page.frameLocator() returns a scoped object. context.pages() gives you all pages. Nothing changes the "current" context.
// Selenium mental model: "Where am I now?"
// driver.switchTo().frame("payment"); // I'm in the payment frame
// driver.findElement(...); // This looks in the payment frame
// driver.switchTo().defaultContent(); // Now I'm back in the main page
// ... forget this line and everything breaks
// Playwright mental model: "I always say exactly where I'm looking"
const paymentFrame = page.frameLocator('#payment');
await paymentFrame.getByLabel('Card').fill('4242...'); // scoped to frame
await page.getByRole('button', { name: 'Pay' }).click(); // main page — no switchingIn Selenium (Java), assertions are synchronous: assertEquals("Dashboard", heading.getText()). In Playwright, web-first assertions are async and must be awaited:
// WRONG — assertion runs detached, test may pass before it resolves
expect(page.getByRole('heading')).toHaveText('Dashboard'); // missing await!
// CORRECT
await expect(page.getByRole('heading')).toHaveText('Dashboard');Missing await is the number one Playwright beginner mistake. Your linter should flag this. Enable @typescript-eslint/no-floating-promises or the Playwright ESLint plugin.
Selenium tests typically run sequentially (one browser, one thread). Playwright Test runs test files in parallel by default. This means:
- Tests must be isolated -- no shared state between test files
- Each test gets its own browser context (fresh cookies, storage, session)
- Database fixtures must not collide between parallel tests
If your Selenium suite depends on execution order, you must fix that before migrating. See core/test-organization.md for isolation strategies.
These are not just syntax differences. These are capabilities that Selenium does not have.
Every action, every assertion, every navigation auto-waits. You write zero wait code. This alone eliminates 60-80% of Selenium test flakiness.
When a test fails in CI, Playwright captures a trace: screenshots at every step, DOM snapshots, network requests, console logs. Open it with npx playwright show-report and step through the exact failure. Selenium has nothing comparable.
// playwright.config.ts — enable traces on first retry
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});Playwright Test runs test files in parallel with zero configuration. No Selenium Grid. No TestNG parallel suite XML. No pytest-xdist.
# Run all tests in parallel (default)
npx playwright test
# Shard across CI machines
npx playwright test --shard=1/4 # machine 1
npx playwright test --shard=2/4 # machine 2The same test runs on Chromium, Firefox, and WebKit without code changes. Configure in playwright.config:
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Selenium requires separate driver binaries and often browser-specific workarounds.
Playwright can intercept, modify, and mock network requests natively. Selenium cannot.
// Mock an API response — impossible in Selenium
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ name: 'Mock User' }]),
});
});Each test gets a fresh browser context (like an incognito window). Cookies, localStorage, and sessions are isolated between tests without restarting the browser. Selenium requires driver.quit() and new ChromeDriver() for true isolation.
Playwright's fixture system provides dependency injection for test setup and teardown. Fixtures guarantee cleanup even if a test crashes. Selenium relies on @BeforeEach / @AfterEach which skip teardown on hard failures.
// Custom fixture for authenticated user — reusable across all tests
import { test as base } from '@playwright/test';
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await use(page);
// Cleanup runs automatically, even on crash
},
});Generate tests by recording browser interactions:
npx playwright codegen https://myapp.comThis opens a browser and records your clicks, fills, and navigations as Playwright test code. No equivalent exists in Selenium.
Debug tests visually with time-travel:
npx playwright test --uiStep forward and backward through each action, see the DOM state, network requests, and console logs at every point. Selenium's closest equivalent is manual Thread.sleep() and screenshot debugging.
- core/locators.md -- locator strategy priority and patterns
- core/assertions-and-waiting.md -- auto-waiting and web-first assertions in depth
- core/configuration.md -- setting up
playwright.config - core/page-object-model.md -- page object patterns for Playwright
- core/fixtures-and-hooks.md -- fixtures system for test setup and isolation
- core/test-organization.md -- organizing tests for parallel execution
- migration/from-cypress.md -- migrating from Cypress instead