01 · Playwright for Python¶
Playwright is a newer browser automation library from Microsoft. It talks to Chromium, Firefox, and WebKit over a single protocol instead of driving each browser's native driver the way Selenium does. The practical payoff: faster execution, built-in auto-waiting, and — notably for this course — it runs headlessly in sandboxed environments where Selenium's Chrome driver often refuses to launch. Every example below was actually executed headlessly while writing this module.
1. Install¶
playwright install downloads a matched browser binary — it is not the
system Chrome, so version drift between your machine and CI mostly disappears.
2. Your first test¶
from playwright.sync_api import sync_playwright
def test_example():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://example.com")
assert page.title() == "Example Domain"
browser.close()
That ran, unmodified, in this environment — no --no-sandbox flags, no
display server, no workarounds. If you hit BrowserType.launch: Executable
doesn't exist, you skipped playwright install chromium.
3. Fixtures: browser once, page per test¶
Launching a browser process is expensive; opening a new page is cheap. Scope
accordingly, exactly like you scoped the Selenium driver fixture in Level 1
— except now the shared thing is the browser, and the per-test thing is a
context (an isolated, cookie-free session) plus a page inside it.
# conftest.py
import pytest
from playwright.sync_api import sync_playwright
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()
@pytest.fixture
def page(browser):
context = browser.new_context()
pg = context.new_page()
yield pg
context.close()
Every test gets a brand-new context — no leaked cookies or localStorage between tests, without you writing any cleanup code. This is the built-in version of the "clear cookies between tests" fixture you had to hand-roll for Selenium.
4. Locators and auto-waiting¶
Selenium's find_element looks once, immediately, and throws if the element
isn't there yet — which is why Level 1 leaned so hard on WebDriverWait.
Playwright's Locator is lazy: it doesn't query the DOM until you act on it,
and then it retries automatically until the element is actionable or a
timeout elapses.
def test_text_input_and_submit(page):
page.goto("https://www.selenium.dev/selenium/web/web-form.html")
page.fill("#my-text-id", "hello playwright")
page.click("button[type=submit]")
page.wait_for_selector("#message")
assert page.inner_text("#message") == "Received!"
No explicit WebDriverWait(driver, 10).until(...) needed for the click or the
fill — Playwright already waited for the button to be visible, enabled, and
stable before clicking it. You still need wait_for_selector for content that
appears after an action, same as an explicit wait in Selenium.
5. Auto-waiting for state changes¶
def test_auto_waiting_for_hidden_element(page):
page.goto("https://www.selenium.dev/selenium/web/dynamic.html")
page.click("#adder")
locator = page.locator("#box0")
locator.wait_for(state="visible")
assert locator.is_visible()
#box0 doesn't exist in the DOM until the button adds it via JavaScript.
locator.wait_for(state="visible") polls for both existence and visibility
— a NoSuchElementException and a TimeoutException from Selenium collapsed
into a single wait call.
6. Testing-specific traps¶
Trap 1 — page.click() succeeding on a covered element. Playwright
checks that the target is not obscured by another element before clicking,
and raises a timeout with a screenshot-style trace if it stays covered. This
catches overlay/modal bugs that Selenium's ElementClickInterceptedException
also catches, but Playwright's error message names the specific intercepting
element, which saves real debugging time.
Trap 2 — strict mode locator ambiguity. page.locator("button") matching
three buttons raises immediately instead of silently acting on the first one
(Selenium's find_element behavior). Prefer get_by_role, get_by_text, or
get_by_test_id over raw CSS to keep locators unambiguous and readable:
Trap 3 — mixing sync and async APIs. Playwright ships both a
sync_api and an async_api. Importing sync_playwright inside an async
def test (or vice versa) raises a confusing
Fixture "page" called from event loop type error. Stick to sync_api unless
your whole suite is already pytest-asyncio based — mixing the two per file
is a common source of "works locally, fails in CI" flakiness.
Trap 4 — session-scoped browser leaking state. Because browser is
session-scoped for speed, if you accidentally create pages directly on
browser (skipping new_context()) they do share cookies across tests —
identical to reusing one Selenium driver across the whole suite. Always go
through a fresh context per test.
How It Actually Works¶
Playwright abandons the WebDriver wire-protocol model entirely in favor of a single
persistent WebSocket connection to the browser's own remote-debugging protocol
(Chrome DevTools Protocol for Chromium, analogous protocols for Firefox/WebKit that
Playwright's team patches directly into those engines). Instead of Selenium's
request/response HTTP round trip per command, Playwright's Node.js-based browser
driver process stays connected over that socket and can both send commands and
receive push events — this is the mechanical reason Playwright's auto-waiting
doesn't need explicit WebDriverWait polling loops: it subscribes to real DOM
mutation and network events from the browser and resolves a locator action the
instant the element becomes actionable, rather than polling on a fixed interval.
"Auto-waiting" specifically means Playwright's .click() internally performs a
sequence of actionability checks (attached to DOM, visible, stable — not still
animating, receives events — not obscured by another element) before dispatching the
actual click event, re-checking each condition via the CDP connection's live state
rather than a cached snapshot. This is why Playwright dramatically reduces flaky
waits compared to Selenium for the same test logic: it's not a smarter guess about
timing, it's checking the real, current DOM state through an always-open channel
instead of intermittently polling through a request/response protocol.
Browser contexts (browser.new_context()) are cheap, isolated sessions within one
already-launched browser process — separate cookie jars, storage, and cache per
context — which is why Playwright tests can spin up dozens of isolated "browser
sessions" per test run far faster than Selenium can launch dozens of separate browser
processes.
Cheat sheet¶
| Selenium | Playwright | Notes |
|---|---|---|
webdriver.Chrome() |
p.chromium.launch() |
Playwright manages the binary itself |
driver.find_element(By.ID, "x") |
page.locator("#x") |
Playwright locator is lazy, re-queries each action |
WebDriverWait(...).until(EC.visibility_of_element_located(...)) |
locator.wait_for(state="visible") |
Auto-waiting removes most explicit waits |
driver.get_cookie(...) |
context.cookies() |
Scoped to a context, not global |
One driver per suite (careful cleanup) |
One context per test (cheap, isolated) |
Contexts replace incognito-window tricks |
ElementClickInterceptedException |
Timeout naming the blocking element | Better diagnostics out of the box |
| N/A | page.screenshot(path=...) |
Built-in, no extra library |
| N/A | Trace viewer (context.tracing) |
Records a full timeline + screenshots per test |
Exercise¶
Using https://www.selenium.dev/selenium/web/web-form.html and
https://www.selenium.dev/selenium/web/dynamic.html:
- Write the
conftest.pyabove with session-scopedbrowserand function-scopedpagefixtures. - Write five tests: page title, filling and submitting the text input,
selecting a dropdown option with
page.select_option, checking the checkbox withpage.check, and the dynamic "add box" scenario from section 5. - Rewrite one test using
get_by_role/get_by_labelinstead of CSS selectors and note which version reads more like the user's own actions. - Deliberately call
page.locator("button")on the web-form page (it has more than one button) with an action like.click(), capture the strict mode violation error, and fix it by scoping the locator more precisely. - Add
context.tracing.start(screenshots=True, snapshots=True)before your test andcontext.tracing.stop(path="trace.zip")after; open the trace withplaywright show-trace trace.zipand describe what it shows that a pytest failure log doesn't.