What is Playwright?
Playwright is an open-source end-to-end testing framework developed and maintained by Microsoft. Released in 2020, it rapidly became one of the most popular browser automation tools due to its multi-browser support, built-in auto-wait, and rich language bindings. Playwright supports Chromium, Firefox, and WebKit (Safari's engine) — giving you genuine cross-browser coverage including Safari without needing a macOS machine.
Unlike Selenium, which relies on browser-specific driver executables (chromedriver, geckodriver), Playwright ships its own browser builds and communicates via the Chrome DevTools Protocol (CDP) for Chromium and a custom protocol for Firefox and WebKit. This tight integration enables features like network interception, geolocation emulation, and browser context isolation that are difficult or impossible to achieve cleanly through the WebDriver protocol.
Language support
- TypeScript / JavaScript — Primary language, richest documentation
- Python — Full feature parity via
playwright-python; integrates with pytest - Java —
playwright-javafor enterprise teams - .NET / C# —
Microsoft.PlaywrightNuGet package
Architecture Diagram
Playwright communicates with each browser via browser-specific protocols — no external WebDriver executable required:
Installation
Python installation
# Install the Playwright package
pip install playwright
# Install browser binaries (Chromium, Firefox, WebKit)
playwright install
# Install pytest plugin for Playwright
pip install pytest-playwright
TypeScript / Node.js installation
# Create a new project with Playwright initialized
npm init playwright@latest
# Playwright installs browsers automatically
# Project structure created:
# playwright.config.ts
# tests/example.spec.ts
# tests-examples/demo-todo-app.spec.ts
First Test in Python
Python tests use the synchronous sync_playwright API or the async API with asyncio. The pytest plugin handles browser lifecycle automatically via fixtures:
# tests/test_login.py
from playwright.sync_api import Page, expect
def test_valid_login(page: Page):
page.goto("https://example.com/login")
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("SecurePass123")
page.get_by_role("button", name="Log in").click()
# Assert dashboard loaded
expect(page).to_have_url("https://example.com/dashboard")
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
def test_invalid_password(page: Page):
page.goto("https://example.com/login")
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("WrongPassword")
page.get_by_role("button", name="Log in").click()
error = page.get_by_role("alert")
expect(error).to_be_visible()
expect(error).to_contain_text("Invalid email or password")
Run with: pytest tests/ -v
Locators
Playwright's locator API is designed to reflect how users and accessibility tools perceive the page — rather than exposing raw DOM queries. This makes tests more resilient to markup changes.
# Role-based locators (recommended — mirrors accessibility tree)
page.get_by_role("button", name="Submit")
page.get_by_role("link", name="Sign up")
page.get_by_role("textbox", name="Search")
# Label-based (best for form inputs)
page.get_by_label("Email address")
page.get_by_label("Password")
# Placeholder text
page.get_by_placeholder("Enter your email")
# Visible text
page.get_by_text("Welcome back")
# Test ID (data-testid attribute — Playwright's equivalent of data-cy)
page.get_by_test_id("submit-button")
# Alt text for images
page.get_by_alt_text("Company logo")
# CSS selector (fallback)
page.locator(".submit-btn")
page.locator("#login-form input[type='email']")
# Chaining locators
page.locator(".user-card").get_by_role("button", name="Edit")
# nth element
page.get_by_role("listitem").nth(2)
Auto-Wait Explained
Every Playwright action waits for the element to satisfy a set of actionability conditions before proceeding. The specific checks depend on the action type:
| Action | Actionability Checks |
|---|---|
click() |
Visible, stable (not animating), enabled, receives events |
fill() |
Visible, enabled, editable |
check() |
Visible, enabled, not already checked |
select_option() |
Visible, enabled |
expect(locator).to_be_visible() |
Retries until visible or timeout |
expect(locator).to_have_text() |
Retries until text matches or timeout |
This means you almost never need to write time.sleep() or explicit wait loops. Playwright's internal polling handles the timing for you, up to the configured timeout (default: 30 seconds for assertions, 30 seconds for actions).
Assertions
Playwright's expect() API provides web-specific assertions with automatic retry:
from playwright.sync_api import expect
# Visibility
expect(page.get_by_role("dialog")).to_be_visible()
expect(page.locator(".spinner")).to_be_hidden()
# Text
expect(page.get_by_role("heading")).to_have_text("Welcome, Alice")
expect(page.get_by_role("status")).to_contain_text("Success")
# Value (form fields)
expect(page.get_by_label("Email")).to_have_value("user@example.com")
# URL
expect(page).to_have_url("https://example.com/dashboard")
expect(page).to_have_url(re.compile(r"/dashboard"))
# Title
expect(page).to_have_title("Dashboard — MyApp")
# Count
expect(page.get_by_role("listitem")).to_have_count(5)
# Enabled state
expect(page.get_by_role("button", name="Submit")).to_be_enabled()
expect(page.get_by_role("button", name="Submit")).to_be_disabled()
# Checked state
expect(page.get_by_role("checkbox")).to_be_checked()
# Attribute
expect(page.get_by_role("link")).to_have_attribute("href", "/pricing")
Page Object Model in Python
The Page Object Model keeps test logic clean and locators centralised. Here is a complete LoginPage implementation with Playwright:
# pages/login_page.py
from playwright.sync_api import Page, expect
class LoginPage:
URL = "/login"
def __init__(self, page: Page):
self.page = page
self.email_input = page.get_by_label("Email")
self.password_input = page.get_by_label("Password")
self.login_button = page.get_by_role("button", name="Log in")
self.error_alert = page.get_by_role("alert")
def navigate(self):
self.page.goto(self.URL)
def login(self, email: str, password: str):
self.email_input.fill(email)
self.password_input.fill(password)
self.login_button.click()
def expect_error(self, message: str):
expect(self.error_alert).to_be_visible()
expect(self.error_alert).to_contain_text(message)
def expect_dashboard(self):
expect(self.page).to_have_url("/dashboard")
# tests/test_login_pom.py
from pages.login_page import LoginPage
def test_successful_login(page):
login = LoginPage(page)
login.navigate()
login.login("user@example.com", "SecurePass123")
login.expect_dashboard()
def test_invalid_credentials(page):
login = LoginPage(page)
login.navigate()
login.login("user@example.com", "BadPassword")
login.expect_error("Invalid email or password")
Pytest Fixtures
The pytest-playwright plugin provides playwright, browser, and page fixtures out of the box. You can build your own fixtures on top of them:
# conftest.py
import pytest
from playwright.sync_api import Page
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
"""Override browser context settings for all tests."""
return {
**browser_context_args,
"viewport": {"width": 1280, "height": 720},
"base_url": "https://staging.example.com",
"ignore_https_errors": True,
}
@pytest.fixture
def logged_in_page(page: Page):
"""Provide a pre-authenticated page to tests that need it."""
# Log in via API to skip the UI
page.request.post("/api/auth/login", data={
"email": "testuser@example.com",
"password": "SecurePass123"
})
page.goto("/dashboard")
yield page
# Usage in a test:
def test_dashboard_content(logged_in_page: Page):
expect(logged_in_page.get_by_role("heading", name="Dashboard")).to_be_visible()
logged_in_page fixture pattern was the first performance win we achieved. Our login flow involved a SAML redirect sequence that took 4–6 seconds per test in Selenium. By authenticating via the API and injecting session cookies, we cut test setup time by over 80%. Across a suite of 150 tests, this saved more than 12 minutes per CI run.
API Testing with Playwright
Playwright's APIRequestContext lets you make HTTP requests directly within your test suite — useful for seeding data, verifying API responses, or running hybrid UI + API test flows:
from playwright.sync_api import Page, APIRequestContext, expect
def test_api_creates_user(page: Page, playwright):
api = playwright.request.new_context(base_url="https://api.example.com")
# POST to create a user
response = api.post("/users", data={
"name": "Alice Test",
"email": "alice@example.com",
"role": "viewer"
})
assert response.ok
body = response.json()
assert body["email"] == "alice@example.com"
# GET the created user
get_response = api.get(f"/users/{body['id']}")
assert get_response.ok
assert get_response.json()["name"] == "Alice Test"
api.dispose()
def test_ui_reflects_api_data(page: Page, playwright):
"""Create data via API, then verify it appears in the UI."""
api = playwright.request.new_context(base_url="https://api.example.com")
resp = api.post("/products", data={"name": "Widget Pro", "price": 49.99})
product_id = resp.json()["id"]
page.goto(f"/products/{product_id}")
expect(page.get_by_role("heading")).to_have_text("Widget Pro")
api.dispose()
Screenshots and Video
Playwright has first-class support for capturing screenshots and recording test video — invaluable for debugging CI failures:
# Manual screenshot in a test
page.screenshot(path="screenshots/login-error.png")
# Full-page screenshot
page.screenshot(path="screenshots/full-page.png", full_page=True)
# Screenshot of a specific element
page.locator(".error-banner").screenshot(path="screenshots/error.png")
Configure automatic screenshots and video in pytest.ini or via CLI:
# pytest.ini
[pytest]
addopts = --screenshot=only-on-failure --video=retain-on-failure --output=test-results
# Or via CLI
pytest tests/ --screenshot=on --video=on
Multi-Browser Testing
Run the same tests across Chromium, Firefox, and WebKit using the --browser flag or by parametrizing in conftest.py:
# Run on all three browsers from CLI
pytest tests/ --browser chromium --browser firefox --browser webkit
# Or run a specific browser
pytest tests/ --browser webkit # Tests against Safari engine
# conftest.py — parametrize by browser
import pytest
@pytest.fixture(params=["chromium", "firefox", "webkit"])
def browser_type_launch_args(request, browser_type_launch_args):
return {**browser_type_launch_args, "browser_name": request.param}
GitHub Actions CI
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install pytest pytest-playwright
playwright install --with-deps chromium firefox webkit
- name: Run Playwright tests
run: pytest tests/ --screenshot=only-on-failure --video=retain-on-failure
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-results
path: test-results/
Tool Comparison
| Feature | Playwright | Cypress | Selenium |
|---|---|---|---|
| Language support | JS, TS, Python, Java, .NET | JavaScript / TypeScript only | Java, Python, JS, C#, Ruby |
| Browser coverage | Chromium, Firefox, WebKit (Safari) | Chrome, Firefox, Edge, Electron | All major browsers |
| Auto-wait | Yes — actionability checks | Yes — command retry | No — explicit waits required |
| Speed | Very fast | Very fast | Moderate |
| API testing | Built-in (APIRequestContext) | cy.request() (basic) | Requires separate library |
| Mobile emulation | Yes (viewport + device profiles) | Viewport only | Via Appium |
| Parallel execution | Yes (built-in, browser contexts) | Yes (dashboard, parallelisation) | Yes (Selenium Grid) |
| Network interception | Built-in (route / fulfill) | Built-in (cy.intercept) | Requires BrowserMob Proxy |
| Trace / debug | Trace Viewer (excellent) | Time-travel debugger | Screenshot on failure only |
Best Practices
1. Prefer role-based locators
Use get_by_role(), get_by_label(), and get_by_placeholder() over CSS selectors. Role-based locators match how assistive technologies see the page and are far more resilient to UI refactoring.
2. Use fixtures for shared setup
Create a logged_in_page fixture that authenticates via API. Never repeat login logic across test files. Scope to session where possible to share browser state across tests that don't modify it.
3. Avoid hardcoded waits
Never use page.wait_for_timeout(2000). Playwright's auto-wait handles the majority of timing issues. For cases where you genuinely need to wait for an event, use page.wait_for_response(), page.wait_for_url(), or page.wait_for_selector() with a specific condition.
4. Run full cross-browser locally before release
Run Chromium only on PR builds for speed. Add Firefox and WebKit to the nightly run or to the release candidate pipeline. Cross-browser issues most commonly surface in Safari (WebKit) — don't skip it before a major release.
Playwright vs Selenium — When to Choose Which
One of the most common questions teams ask when evaluating Playwright is whether it replaces Selenium. The short answer is: not always. Playwright is a better fit for many modern web projects, but Selenium retains real advantages in specific contexts. Understanding those differences helps you make an informed choice rather than following hype.
Where Playwright wins
- Auto-wait without explicit waits. Every Playwright action performs built-in actionability checks — visible, enabled, stable — before executing. You never write
WebDriverWait(driver, 10).until(EC.element_to_be_clickable(...))boilerplate again. This alone eliminates the most common source of Selenium flakiness. - Built-in API testing via the
requestfixture. TheAPIRequestContextlets you mix HTTP calls and browser steps in a single test file. Selenium requires a separate library such asrequestsor RestAssured and manual session-cookie handoff between API and UI layers. - Native multi-tab and iframe support. Opening a new tab, switching between pages, and reaching into nested iframes are all first-class operations in Playwright. Selenium's
driver.switch_to.frame()and window-handle management are verbose and fragile by comparison. - Built-in screenshot and video on failure. Configure
--screenshot=only-on-failure --video=retain-on-failurein one line. Selenium requires third-party reporters or manualdriver.get_screenshot_as_file()calls wired to a test listener. - Faster execution via CDP. Playwright talks directly to Chromium over the Chrome DevTools Protocol rather than through the WebDriver HTTP layer. Network roundtrip overhead is lower, and features like network interception and request mocking are trivially easy to implement.
Where Selenium still wins
- Longer community history and job market presence. Selenium has been the industry standard since 2004. Job postings for "Selenium" still outnumber "Playwright" in most markets, and the volume of Stack Overflow answers, blog posts, and training courses remains larger.
- Java and C# ecosystem integration. Selenium's Java bindings pair naturally with TestNG, JUnit 5, Maven, and the full Java reporting ecosystem. If your team is Java-first, Selenium + TestNG is a mature, battle-tested combination. Playwright's Java support is functional but less polished than its Python and TypeScript bindings.
- Selenium Grid for scaling. Selenium Grid 4 with Docker or Kubernetes is a proven solution for distributing tests across dozens of parallel nodes and real operating systems. Playwright's built-in parallelism is excellent for a single machine but does not replicate the cross-machine, cross-OS distribution model that Selenium Grid provides.
- Better support for legacy browsers. Need to test Internet Explorer 11 or an older Edge? Selenium with IEDriverServer is your only real option. Playwright does not support IE at all.
- Wider plugin and reporting ecosystem. ExtentReports, Allure-Java, ReportNG, and many Maven plugins integrate natively with Selenium-based frameworks. Playwright's reporting ecosystem is growing but not as deep, especially on the Java side.
Decision guide
Choose Playwright when: you are starting a greenfield web project with no existing test infrastructure; the application is a modern React, Angular, or Vue SPA; your team writes Python or TypeScript; or you need API testing and UI testing in a single, unified suite without pulling in additional libraries.
Choose Selenium when: you are extending an existing Java or TestNG codebase and a rewrite is not on the table; your enterprise environment relies on Selenium Grid for cross-machine parallel execution; your team's expertise is strongly Java-based and the productivity cost of switching languages outweighs Playwright's feature advantages; or you need to test legacy browsers that Playwright does not support.
Common Playwright pain points
- Flaky mobile emulation vs real device testing. Playwright's mobile emulation adjusts the viewport and user-agent string, but it is not a substitute for running on a real iOS or Android device. For genuine mobile coverage, you still need a cloud device farm such as BrowserStack or AWS Device Farm.
- Less mature Java support. The
playwright-javalibrary is fully functional, but the documentation, community examples, and IDE integration are thinner than what you get with the Python or TypeScript bindings. Java teams evaluating Playwright should budget extra ramp-up time. - Steep learning curve for the page-fixture model. Playwright's pytest plugin delivers the
pageobject through a fixture rather than instantiating it in a base class the way most Selenium frameworks do. This is more powerful but initially confusing for engineers coming from a Selenium Page Object background.
Quick comparison table
| Criterion | Playwright | Selenium |
|---|---|---|
| Language support | Python, TypeScript, Java, .NET (Python/TS best-supported) | Java, Python, C#, Ruby, JavaScript (Java strongest) |
| Auto-wait | Built-in actionability checks on every action | Manual — requires explicit WebDriverWait calls |
| API testing | Built-in via APIRequestContext / request fixture | Requires separate library (requests, RestAssured) |
| Mobile testing | Emulation only; real devices need external farm | Real devices via Appium (same WebDriver protocol) |
| Community size | Large and fast-growing (since 2020) | Very large — 20+ years of resources and answers |
| CI setup complexity | Low — one install step, browsers bundled | Medium — requires managing driver versions separately |
| Learning curve | Moderate — fixture model and async patterns to learn | Low initial, high advanced (Grid, wait strategies) |
| Job market demand | Rising — increasingly listed in modern QA roles | High — still dominant in enterprise job postings |
Back to Blog