Playwright
Playwright
Definition: Microsoft’s end-to-end browser testing framework, built to automate Chromium, Firefox, and WebKit through one consistent API, using each browser’s own low-level automation protocol rather than a single shared standard.
How It Works
- A single Playwright API drives three different browser engines. For Chromium it speaks the Chrome DevTools Protocol (CDP); for Firefox and WebKit it talks to patched builds via their own internal automation protocols, all wrapped so the test author never sees the difference.
- Each browser is launched as a real, separate process. Playwright’s Node (or Python/Java/.NET) process communicates with it over a WebSocket connection, not HTTP polling, which keeps command latency low.
- A
BrowserContextis an isolated session, its own cookies, storage, and cache, similar to an incognito window. Tests typically get a fresh context each run instead of a fresh browser launch, which is much cheaper. - Locators (
page.getByRole(),page.getByTestId()) are lazy and auto-retrying: they don’t resolve to an element until an action is performed, and every action waits for the element to be visible, stable, and enabled first. - Network interception (
page.route()) can block, modify, or fulfill requests before they hit the network, useful for mocking APIs or blocking analytics calls during tests. - Visual comparisons (
expect(page).toHaveScreenshot()) diff a rendered page against a stored baseline image pixel by pixel, catching layout regressions that DOM assertions can’t. - The Trace Viewer records a full timeline, DOM snapshots, network calls, console logs, screenshots per test, replayable after the fact, especially useful for debugging CI-only failures.
codegenrecords manual clicks in a real browser and emits working Playwright test code, useful for scaffolding a first draft of a test.- Fixtures (
test.extend()) provide reusable setup, like a logged-inpage, injected by parameter name into any test that declares it, similar in spirit to PyTest’s fixture model. - Playwright Test, the built-in runner, parallelizes across worker processes automatically and can shard a suite across multiple CI machines with
--shard=1/4style flags. expect()assertions in Playwright Test are web-first: they poll the DOM until the condition is true or a timeout elapses, the same auto-retry philosophy applied to assertions, not just actions.- Mobile emulation ships built in, device descriptors (
devices['iPhone 13']) configure viewport, user agent, and touch support without a separate tool.
Under the Hood
One test file, run with --project=chromium --project=firefox --project=webkit, executes the identical script against all three engines, and any browser-specific rendering or timing bug shows up as a failure in exactly the project where it occurs.
Because each engine gets its own real, unpatched-behavior process, this catches genuine rendering and timing differences, not just API differences, the kind of bug a single-engine tool structurally cannot see.
Given a checkout form and a mocked payment API Step:
import { test, expect } from '@playwright/test'
test('shows confirmation after successful payment', async ({ page }) => {
await page.route('**/api/payment', route =>
route.fulfill({ status: 200, body: JSON.stringify({ status: 'approved' }) })
)
await page.goto('/checkout')
await page.getByLabel('Card number').fill('4242424242424242')
await page.getByRole('button', { name: 'Pay now' }).click()
await expect(page.getByText('Payment approved')).toBeVisible()
})
Answer: page.route intercepts the real /api/payment call and returns a fake success response. Playwright then clicks the real button, waits for the DOM to update, and retries the toBeVisible() assertion until the confirmation text renders or the test times out.
Given a button disabled until a form is valid
Step: await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled()
Answer: Playwright polls the button’s disabled attribute automatically, no explicit wait needed, and only proceeds once it’s actually clickable, avoiding a race where a click fires before the form’s validation state settles.
This same polling model backs every expect(locator) assertion, not just element state checks, so text content, visibility, and attribute assertions all get the same automatic retry for free.
Why It Matters
- True multi-engine coverage catches real cross-browser bugs (WebKit date input quirks, Firefox flexbox differences) that Chromium-only tools miss entirely.
- Auto-waiting locators eliminate most of the manual
waitForSelector/sleepcalls that make Selenium suites brittle. - Isolated browser contexts make parallel test execution cheap: many tests can share one browser process instead of launching a new browser per test.
- One API across three languages and three engines means QA and developers on different stacks can share the same mental model of how tests are written and debugged.
- The Trace Viewer turns “it failed in CI and I can’t reproduce it” into a step-by-step visual replay, without adding print statements and re-running.
- Sharding and worker-based parallelism built into the runner mean large suites scale across CI machines without a separate orchestration layer like Selenium Grid.
- Built-in visual regression tooling removes the need for a separate screenshot-diffing service in many projects.
Common Pitfalls
- Falling back to the older
page.$()/page.$$()element-handle API instead of locators, which loses the automatic retry-and-wait behavior locators provide. - Using
page.waitForTimeout()as a substitute for waiting on a real condition, reintroducing the exact flakiness auto-waiting is meant to remove. - Sharing one browser context across unrelated tests to save time, which leaks cookies and storage state between tests that should be independent.
- Ignoring the trace and video artifacts CI already captured, and instead trying to reproduce a flaky failure by staring at the test code.
- Writing brittle selectors tied to CSS classes or DOM structure instead of accessible roles/labels via
getByRole/getByLabel, which also break when the app’s markup changes for unrelated reasons. - Running heavy setup (login, seeding data) through the UI in every test instead of using
storageStateor API calls, making the suite far slower than it needs to be. - Assuming all three browser engines behave identically and only ever testing against Chromium, quietly losing the main advantage Playwright offers over Chromium-only tools.
- Reusing a single
pageobject across many unrelated test cases in one test function instead of letting the fixture create a fresh one per test, which reintroduces cross-test state leakage.
Comparison
| Playwright | Cypress | Selenium | Puppeteer | |
|---|---|---|---|---|
| Browser engines | Chromium, Firefox, WebKit | Chromium family, Firefox, WebKit (experimental) | Chromium, Firefox, Safari, Edge | Chromium (and limited Firefox) |
| Architecture | Node process drives browsers via native protocols | Runs inside the browser, same process as app | Client to separate WebDriver process, over HTTP | Node process drives Chromium via CDP directly |
| Multi-tab/context | Native, cheap isolated contexts | Limited, needs cy.origin() | Native, but more manual | Native |
| Language bindings | JS/TS, Python, Java, .NET | JavaScript/TypeScript only | JS, Python, Java, C#, Ruby, more | JavaScript/TypeScript only |
| Mobile emulation | Built-in device descriptors | Limited viewport emulation | Via Appium, separate tool | Built-in device descriptors |
| Debugging | Trace Viewer with full timeline replay | Time-travel snapshots in the runner UI | Screenshots plus external logging | Basic screenshots/video |
| Parallelism | Built-in workers, native sharding | Parallel via Cypress Cloud (paid) | Selenium Grid, separate infra | Manual, no built-in runner |
Example
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context()
page = context.new_page()
page.goto("https://example.com/login")
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("secret123")
page.get_by_role("button", name="Log in").click()
page.wait_for_url("**/dashboard")
assert page.get_by_text("Welcome back").is_visible()
browser.close()
The Python sync API mirrors the JS one closely: same locator methods, same auto-waiting behavior, just a different language binding on top of the identical underlying protocol layer.
Swapping p.chromium for p.firefox or p.webkit runs the exact same script against a different engine, no other code changes required.
Related Terms
- Cypress — the closest competitor, trading multi-browser breadth for an in-browser architecture
- Selenium — the older WebDriver-based standard Playwright was built to improve on
- Jest — sometimes used for unit tests alongside Playwright for e2e, though Playwright Test has its own runner
- Test Pyramid and TDD — where Playwright’s e2e tests sit relative to unit and integration layers
- CI-CD — headless Playwright runs are a standard pipeline stage before deploy
- REST API — what
page.route()typically intercepts and mocks - PyTest — Playwright’s Python bindings integrate directly into
pytest-playwrightfixtures - JUnit — Playwright’s Java binding plugs into JUnit 5 the same way any other JVM test dependency does