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 BrowserContext is 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.
  • codegen records 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-in page, 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/4 style 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/sleep calls 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 storageState or 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 page object 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

PlaywrightCypressSeleniumPuppeteer
Browser enginesChromium, Firefox, WebKitChromium family, Firefox, WebKit (experimental)Chromium, Firefox, Safari, EdgeChromium (and limited Firefox)
ArchitectureNode process drives browsers via native protocolsRuns inside the browser, same process as appClient to separate WebDriver process, over HTTPNode process drives Chromium via CDP directly
Multi-tab/contextNative, cheap isolated contextsLimited, needs cy.origin()Native, but more manualNative
Language bindingsJS/TS, Python, Java, .NETJavaScript/TypeScript onlyJS, Python, Java, C#, Ruby, moreJavaScript/TypeScript only
Mobile emulationBuilt-in device descriptorsLimited viewport emulationVia Appium, separate toolBuilt-in device descriptors
DebuggingTrace Viewer with full timeline replayTime-travel snapshots in the runner UIScreenshots plus external loggingBasic screenshots/video
ParallelismBuilt-in workers, native shardingParallel via Cypress Cloud (paid)Selenium Grid, separate infraManual, 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.

  • 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-playwright fixtures
  • JUnit — Playwright’s Java binding plugs into JUnit 5 the same way any other JVM test dependency does

Dig deeper