08 · Migrating from Selenium¶
Teams rarely rewrite an entire Selenium suite overnight. This module covers both the concept mapping (so existing Selenium knowledge transfers instead of feeling thrown away) and a practical strategy for migrating incrementally without a multi-month test-freeze.
Concept mapping¶
Selenium (WebDriver) Playwright
─────────────────────────────────────────────────────────────
WebDriver Browser + BrowserContext
driver.find_element(By.ID, "x") page.locator("#x")
driver.find_elements(...) locator (already plural; use .all())
WebDriverWait + expected_conditions built into every action + expect()
driver.get(url) page.goto(url)
element.send_keys(text) locator.fill(text) / locator.type(text)
element.click() locator.click()
Actions class (hover, drag) page.hover(), locator.drag_to()
driver.switch_to.frame(...) page.frame_locator(...)
driver.switch_to.window(...) context.pages / page.on("popup")
Page Object Model same pattern, same intent — locators
just get lazier and auto-waiting
The single biggest mental shift: explicit waits become unnecessary¶
# Selenium — waiting is your responsibility, everywhere
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
button = wait.until(EC.element_to_be_clickable((By.ID, "submit")))
button.click()
# Playwright — the click call itself waits
page.locator("#submit").click()
# porting a Selenium test 1:1 by wrapping every action in an
# explicit wait is not wrong, but it's redundant — Playwright's
# actionability checks (attached, visible, stable, enabled,
# receives events) already run before every action; the
# migration payoff is largely in DELETING the wait boilerplate,
# not adding a Playwright-flavored equivalent of it
Handling time.sleep() calls carried over from Selenium¶
# carried-over anti-pattern, now doubly unnecessary
page.locator("#save").click()
time.sleep(2)
assert page.locator("#success-message").is_visible()
# correct Playwright equivalent
page.locator("#save").click()
expect(page.locator("#success-message")).to_be_visible()
# is_visible() alone (not wrapped in expect) returns the
# CURRENT state instantly with no retry — a common migration
# bug is replacing sleep+assert with is_visible() and calling
# it done, when it still has the same race condition the sleep
# was papering over; expect() is what actually adds the
# auto-retrying wait
An incremental migration strategy¶
1. New tests: write in Playwright from day one, even while the
bulk of the suite is still Selenium — don't let the backlog
grow.
2. Shared CI: run both suites as separate jobs in the same
pipeline so nobody loses coverage during the transition.
3. Migrate by risk, not by ease: prioritize the flakiest,
highest-maintenance Selenium tests first — that's where
Playwright's auto-waiting pays off fastest and most visibly,
building the case for continuing.
4. Delete the Selenium test the same PR that adds its Playwright
replacement — don't let both versions of the same test
coexist indefinitely; that's strictly worse than either
suite alone (double maintenance, double flakiness surface).
5. Track migration % as a real metric so the effort doesn't
silently stall at 60%.
Side-by-side worked example: a login test¶
# Selenium version
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def test_login_selenium():
driver = webdriver.Chrome()
driver.get("https://example.com/login")
driver.find_element(By.ID, "email").send_keys("user@example.com")
driver.find_element(By.ID, "password").send_keys("secret")
driver.find_element(By.ID, "submit").click()
wait = WebDriverWait(driver, 10)
heading = wait.until(EC.presence_of_element_located((By.TAG_NAME, "h1")))
assert heading.text == "Dashboard"
driver.quit()
# Playwright equivalent
from playwright.sync_api import sync_playwright, expect
def test_login_playwright():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com/login")
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("secret")
page.get_by_role("button", name="Sign in").click()
expect(page.get_by_role("heading")).to_have_text("Dashboard")
browser.close()
# note the locator style change too, not just the API: the
# migration is also a good opportunity to move from ID-based
# selectors (fragile, meaningless to future readers) to
# role/label locators (Level 1), rather than mechanically
# translating By.ID("email") into page.locator("#email")
How It Actually Works¶
The concept mapping in this module reflects a genuine architectural
difference, not just an API rename. Selenium's WebDriver protocol is a
stateless HTTP REST API: every command (find_element, click,
send_keys) is its own independent HTTP request/response round trip to a
separate driver executable (chromedriver, geckodriver), which then issues
its own commands to the browser — two network hops per action, with no
persistent connection and no built-in mechanism for the driver to push
events back to your test code. This is precisely why Selenium needs
WebDriverWait/expected_conditions as an explicit, separate polling
construct: nothing in the underlying request/response model waits for you,
so every wait has to be hand-built by re-issuing HTTP requests in a loop
until a condition holds.
Playwright's CDP-based model (Level 4 Module 6) keeps one persistent
WebSocket connection over which the browser can push events
(Page.lifecycleEvent, DOM.attributeModified, and the accessibility-tree
state actionability checks read) proactively, and the actionability retry
loop is built into the client library itself rather than left to test
authors — this is the structural reason "explicit waits become unnecessary"
during migration isn't a style preference, it's that the retry mechanism
Selenium requires you to hand-roll per test is something Playwright's
architecture makes redundant by construction. element.send_keys(text)
mapping to locator.fill(text) carries a similar architectural note: fill
sets the DOM value directly and fires one input/change event pair
(Level 1 Module 5), while Selenium's send_keys dispatches real per-
character key events — the closer Playwright equivalent of that specific
behavior is press_sequentially(), not fill(), which matters for any
migrated test relying on keystroke-reactive JavaScript.
Exercise¶
- Take one real Selenium test from an existing suite and port it to
Playwright using the concept mapping table above, replacing every
WebDriverWait/expected_conditionscall with an equivalent auto-waiting action orexpect(). - Find (or introduce) a
time.sleep()in a ported test and replace it with a properexpect()assertion; explain in a comment what race condition the sleep was actually hiding. - Set up a CI pipeline running both a Selenium job and a Playwright job as separate steps, simulating a mid-migration repository.
- Pick the flakiest test in a real or sample Selenium suite, migrate it first, and write a short note on why its flakiness is expected to improve under Playwright's actionability model specifically.