Skip to content

Latest commit

 

History

History
955 lines (710 loc) · 38.5 KB

File metadata and controls

955 lines (710 loc) · 38.5 KB

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 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.

// 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