Files
claude-code-guide/skills/playwright/migration/from-selenium.md
T

978 lines
45 KiB
Markdown

# 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/locators.md), [core/assertions-and-waiting.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:
```bash
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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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)**
```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.
```bash
# 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**
```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**
```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)**
```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)**
```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)**
```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 `By` objects -- they are lazy and never go stale
- No `WebDriverWait` anywhere
- No `clear()` before `fill()`
- 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:
1. **Smoke tests** -- highest value, run on every deploy
2. **Flaky tests** -- Playwright's auto-waiting eliminates most flakiness
3. **Slow tests** -- Playwright's parallelism and faster protocol make these faster
4. **Everything else** -- bulk conversion of the remaining suite
For each test, apply the API mapping table above. The mechanical translation is:
1. Remove all `WebDriverWait` and `ExpectedConditions`
2. Replace `findElement(By.xxx)` with semantic locators (`getByRole`, `getByLabel`, `getByText`)
3. Replace `sendKeys` with `fill` (or `pressSequentially` for character-by-character input)
4. Replace `Assert` / `assertEquals` with `expect(locator).toHaveText()` / `toBeVisible()` / etc.
5. Remove `setUp` and `tearDown` -- Playwright Test handles browser lifecycle
6. 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:
1. Delete Selenium test files
2. Remove Selenium dependencies (`selenium-webdriver`, `chromedriver`, `geckodriver`)
3. Remove Selenium Grid infrastructure
4. Update CI pipelines to run only Playwright
5. Remove `setUp` / `tearDown` boilerplate 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.
```typescript
// 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:
```typescript
// 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".
```typescript
// 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.
```java
// 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.
```typescript
// 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.
```typescript
// 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:
```typescript
// 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](../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.
```typescript
// 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.
```bash
# 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`:
```typescript
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.
```typescript
// 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.
```typescript
// 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:
```bash
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:
```bash
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](../core/locators.md) -- locator strategy priority and patterns
- [core/assertions-and-waiting.md](../core/assertions-and-waiting.md) -- auto-waiting and web-first assertions in depth
- [core/configuration.md](../core/configuration.md) -- setting up `playwright.config`
- [core/page-object-model.md](../core/page-object-model.md) -- page object patterns for Playwright
- [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md) -- fixtures system for test setup and isolation
- [core/test-organization.md](../core/test-organization.md) -- organizing tests for parallel execution
- [migration/from-cypress.md](from-cypress.md) -- migrating from Cypress instead