45 KiB
Migrating from Selenium to Playwright
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
Key Mindset Shifts
Before translating any code, internalize these six changes. They are not syntax swaps -- they are fundamental differences in how Playwright works.
1. No More Explicit Waits
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
2. No WebDriver Protocol Overhead
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.
3. No Driver Management
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 install
No WebDriverManager. No chromedriver path. No version matrix.
4. No Implicit vs Explicit Wait Confusion
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.
5. Locators Are Lazy and Auto-Retry
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.
6. Built-In Test Runner
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.
API Mapping Table
Every Selenium API and its direct Playwright equivalent. The "Notes" column calls out behavior differences.
Navigation
| 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 |
Element Location
| 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 |
Element Interaction
| 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 |
Waits and Conditions
| 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 |
Frames and Windows
| 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 |
Browser and Context
| 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. |
Actions Class
| 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 |
Before/After Examples
Example 1: Login Test
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.
Example 2: Search and Verify Results
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().
Example 3: Working with Iframes
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.
Example 4: Handling Popups and New Windows
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.
Example 5: Drag and Drop with Actions
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.
Migration Steps
A practical, ordered checklist for converting a Selenium suite to Playwright.
Step 1: Set Up Playwright Alongside Selenium
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 dependencies
Configure 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,
})
Step 2: Convert Page Objects First
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
Step 3: Convert Tests by Priority
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
Step 4: Replace Test Infrastructure
| 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' } |
Step 5: Remove Selenium
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
Common Gotchas
Gotcha 1: findElement Throws, locator() Does Not
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 actionTimeout
Impact: 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()
Gotcha 2: sendKeys Appends, fill Replaces
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 value
Gotcha 3: No StaleElementReferenceException
In 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 — StaleElementReferenceException
In 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 automatically
Gotcha 4: No Global Driver State
Selenium 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 switching
Gotcha 5: Assertions Must Be Awaited
In 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.
Gotcha 6: Parallel by Default
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.
What's Better in Playwright
These are not just syntax differences. These are capabilities that Selenium does not have.
Auto-Waiting Everywhere
Every action, every assertion, every navigation auto-waits. You write zero wait code. This alone eliminates 60-80% of Selenium test flakiness.
Trace Viewer
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",
},
})
Built-In Parallel Execution
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 2
Multiple Browsers, One API
The 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.
Network Interception
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" }]),
})
})
Browser Context Isolation
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.
Test Fixtures
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
},
})
Codegen
Generate tests by recording browser interactions:
npx playwright codegen https://myapp.com
This opens a browser and records your clicks, fills, and navigations as Playwright test code. No equivalent exists in Selenium.
UI Mode
Debug tests visually with time-travel:
npx playwright test --ui
Step 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.
Related
- 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