Playwright Python Tutorial 2026: End-to-End Testing and Browser Automation
Playwright has become the dominant browser automation tool for Python in 2026. Backed by Microsoft and now a CNCF Incubating project, it overtook Selenium in new project adoption in 2024 and has not looked back. Search interest is up roughly 60% year-over-year. The reasons are straightforward: Playwright is faster, more reliable, has better developer ergonomics, and ships with a test runner that works across Chromium, Firefox, and WebKit in a single install.
This tutorial walks you through everything from basic navigation to parallel test execution, visual regression testing, network mocking, and CI integration with GitHub Actions.
TL;DR
- Install:
pip install playwright && playwright install - Use the sync API for scripts and quick automation; use the async API for high-concurrency scraping or when integrating with an async web framework
- Prefer semantic locators (
get_by_role,get_by_label,get_by_text) over CSS selectors - Use
expect(page.get_by_role(...)).to_be_visible()for assertions — Playwright retries automatically pytest-playwrightgives you browser fixtures, page objects, and video recording out of the box- The Page Object Model keeps test code maintainable as your suite grows
- Run tests in parallel with
pytest-xdist:pytest -n auto - Mock network requests with
page.route()to isolate front-end tests from back-end flakiness - GitHub Actions: use the official
microsoft/playwright-pythonDocker image or theplaywright installstep
Playwright vs Selenium vs Cypress in 2026: Why Playwright Wins
Three tools still dominate end-to-end testing conversations in 2026. Here is how they compare on the dimensions that matter day-to-day.
| Feature | Playwright | Selenium | Cypress |
|---|---|---|---|
| Browser support | Chromium, Firefox, WebKit | All browsers via WebDriver | Chromium-family only |
| Language support | Python, JS, Java, C#, Go | All major languages | JavaScript / TypeScript only |
| Auto-waiting | Built-in, configurable | Manual waits required | Built-in |
| Network interception | First-class page.route() | Complex, driver-level | cy.intercept() |
| Parallel execution | Built-in sharding + pytest-xdist | Selenium Grid | Cypress Cloud (paid) |
| Install size | ~200 MB (bundled browsers) | ~50 MB + browser drivers | ~300 MB |
| Shadow DOM | Supported | Manual pierce | Limited |
| iframes | frame_locator() | Manual switch | cy.within() |
| Mobile emulation | Device descriptors built-in | Third-party (Appium) | Experimental |
| License | Apache 2.0 | Apache 2.0 | MIT (runner), Commercial (cloud) |
Why Playwright wins in 2026:
Selenium requires you to manage browser drivers (chromedriver, geckodriver) separately, handle explicit waits for nearly every interaction, and live with a WebDriver protocol that was designed in 2012. The gap in developer experience is wide.
Cypress is JavaScript-only. If your team writes Python, Cypress is not an option. Even for JavaScript teams, Cypress's single-tab limitation and inability to test across origins without plugins creates friction on real-world applications.
Playwright ships with bundled browsers, auto-waiting on every action and assertion, a first-class network interception API, built-in screenshot and video recording, and a Python API that feels idiomatic. It supports all three major browser engines, including WebKit (Safari's engine), which matters because Safari bugs are real and Apple's market share is not going away.
Installation
Python 3.8 or later is required. Playwright ships the browser binaries as a separate download step, keeping the PyPI package small.
pip install playwright
playwright install
playwright install downloads Chromium, Firefox, and WebKit into ~/.cache/ms-playwright (Linux/macOS) or %LOCALAPPDATA%\ms-playwright (Windows). To install a specific browser only:
playwright install chromium
playwright install firefox
playwright install webkit
To also install operating-system-level dependencies on a fresh Linux server (common in CI):
playwright install --with-deps chromium
Install pytest-playwright for the test runner integration:
pip install pytest-playwright
Verify the installation:
playwright --version
# Playwright 1.44.0 (or later)
Sync vs Async API: When to Use Each
Playwright for Python ships two APIs that are functionally identical but differ in how they block.
Sync API
The sync API uses Python's threading model under the hood. Each call blocks until Playwright responds. This is the right choice for:
- Test suites (
pytest-playwrightuses the sync API exclusively) - Simple one-off automation scripts
- Any context where
asynciois not already in use
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com")
print(page.title())
browser.close()
Async API
The async API is built on asyncio. Use it when:
- You are running many browsers in parallel for scraping or load simulation
- You are integrating Playwright into a FastAPI or Starlette application
- You already have an async event loop and want to avoid thread overhead
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto("https://example.com")
print(await page.title())
await browser.close()
asyncio.run(main())
For the rest of this tutorial, code examples use the sync API unless noted. In pytest-playwright, everything is sync by default.
Basic Navigation, Clicking, and Filling Forms
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False) # headed for debugging
page = browser.new_page()
# Navigation
page.goto("https://the-internet.herokuapp.com/login")
# Filling form fields
page.get_by_label("Username").fill("tomsmith")
page.get_by_label("Password").fill("SuperSecretPassword!")
# Clicking a button
page.get_by_role("button", name="Login").click()
# Waiting for navigation to complete
page.wait_for_url("**/secure")
# Reading text content
message = page.get_by_css(".flash.success").inner_text()
print(message) # "You logged into a secure area!"
browser.close()
Playwright auto-waits before every action. When you call .click(), Playwright waits for the element to be visible, stable (not animating), and enabled before dispatching the click event. You rarely need explicit time.sleep() or wait_for_selector() calls.
Key navigation methods:
page.goto("https://example.com") # navigate to URL
page.go_back() # browser back
page.go_forward() # browser forward
page.reload() # reload page
page.wait_for_load_state("networkidle") # wait until no network activity
page.wait_for_url("**/dashboard") # wait for URL pattern
Locators: Best Practices
Locators are Playwright's way of finding elements. The cardinal rule: prefer locators that reflect how users find elements, not implementation details like CSS classes or database IDs.
Preferred: Semantic Locators
# By ARIA role and accessible name
page.get_by_role("button", name="Submit")
page.get_by_role("heading", name="Welcome back")
page.get_by_role("link", name="Sign in")
page.get_by_role("textbox", name="Email address")
# By label text (great for form inputs)
page.get_by_label("Email address")
page.get_by_label("Password")
# By visible text
page.get_by_text("Forgot your password?")
page.get_by_text("Forgot your password?", exact=True)
# By placeholder
page.get_by_placeholder("Search...")
# By alt text (images)
page.get_by_alt_text("Company logo")
# By test id (set data-testid in your HTML)
page.get_by_test_id("submit-button")
When CSS Selectors Are Acceptable
Use CSS selectors when the element has no semantic role and adding a data-testid is not an option:
page.locator(".flash-message")
page.locator("#main-content > h1")
page.locator("table tbody tr:nth-child(2) td:nth-child(3)")
Chaining and Filtering Locators
# Find a row in a table that contains "Alice", then get the Edit button in that row
page.get_by_role("row").filter(has_text="Alice").get_by_role("button", name="Edit").click()
# Scope a locator inside a parent element
sidebar = page.locator(".sidebar")
sidebar.get_by_role("link", name="Settings").click()
Why Avoid CSS Selectors as a Default
CSS classes and IDs change during refactoring. A test that relies on .btn-primary or #app > div:nth-child(3) breaks whenever a developer renames a class or restructures the DOM. Semantic locators survive refactoring because they match what users see.
Assertions with expect()
Playwright's expect() function implements web-first assertions: it retries the assertion for up to 5 seconds (by default) before failing. This eliminates the most common source of test flakiness — race conditions between your assertion and the page updating.
from playwright.sync_api import expect
# Visibility
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
expect(page.locator(".spinner")).to_be_hidden()
# Text content
expect(page.get_by_role("status")).to_have_text("Saved successfully")
expect(page.get_by_role("status")).to_contain_text("Saved")
# Input values
expect(page.get_by_label("Username")).to_have_value("alice")
# URLs
expect(page).to_have_url("https://app.example.com/dashboard")
expect(page).to_have_url(re.compile(r"/dashboard$"))
# Page title
expect(page).to_have_title("Dashboard | MyApp")
# Counts
expect(page.get_by_role("listitem")).to_have_count(5)
# Checked state
expect(page.get_by_label("Accept terms")).to_be_checked()
# Enabled/disabled
expect(page.get_by_role("button", name="Submit")).to_be_enabled()
expect(page.get_by_role("button", name="Submit")).to_be_disabled()
# CSS class
expect(page.locator(".alert")).to_have_class(re.compile(r"alert-success"))
# Attribute
expect(page.get_by_role("img", name="Logo")).to_have_attribute("src", "/logo.png")
Configure the timeout globally in pytest.ini or conftest.py:
# conftest.py
from playwright.sync_api import expect
expect.set_options(timeout=10_000) # 10 seconds
pytest-playwright: Fixtures, Browser Contexts, and Pages
pytest-playwright provides built-in fixtures that handle browser lifecycle, context isolation, and cleanup.
Built-in Fixtures
| Fixture | Scope | Description |
|---|---|---|
browser | session | A single browser instance shared across all tests |
context | function | A fresh browser context (isolated cookies, storage) per test |
page | function | A new page in the function-scoped context |
browser_type | session | The browser type object (chromium/firefox/webkit) |
# test_login.py
def test_login_success(page):
page.goto("https://example.com/login")
page.get_by_label("Username").fill("alice")
page.get_by_label("Password").fill("secret123")
page.get_by_role("button", name="Login").click()
expect(page).to_have_url("https://example.com/dashboard")
def test_login_wrong_password(page):
page.goto("https://example.com/login")
page.get_by_label("Username").fill("alice")
page.get_by_label("Password").fill("wrong")
page.get_by_role("button", name="Login").click()
expect(page.get_by_role("alert")).to_have_text("Invalid credentials")
Configuring pytest-playwright via pytest.ini
# pytest.ini
[pytest]
addopts = --browser chromium --headed --screenshot on
Command-line Options
# Run in a specific browser
pytest --browser firefox
pytest --browser webkit
pytest --browser chromium
# Run in all browsers
pytest --browser chromium --browser firefox --browser webkit
# Headed mode (see the browser)
pytest --headed
# Slow motion (useful for debugging)
pytest --slowmo 500
# Save screenshots on failure
pytest --screenshot on
# Record video for every test
pytest --video on
# Capture full-page screenshots
pytest --screenshot on --full-page-screenshot
Page Object Model Pattern
The Page Object Model (POM) separates the details of how to interact with a page from the logic of what to test. Each page in your application gets a Python class that encapsulates its locators and actions.
# pages/login_page.py
from playwright.sync_api import Page, expect
class LoginPage:
def __init__(self, page: Page):
self.page = page
self.username_input = page.get_by_label("Username")
self.password_input = page.get_by_label("Password")
self.submit_button = page.get_by_role("button", name="Login")
self.error_alert = page.get_by_role("alert")
def navigate(self):
self.page.goto("/login")
return self
def login(self, username: str, password: str):
self.username_input.fill(username)
self.password_input.fill(password)
self.submit_button.click()
return self
def expect_error(self, message: str):
expect(self.error_alert).to_have_text(message)
return self
# pages/dashboard_page.py
from playwright.sync_api import Page, expect
class DashboardPage:
def __init__(self, page: Page):
self.page = page
self.welcome_heading = page.get_by_role("heading", name=re.compile(r"Welcome"))
self.logout_button = page.get_by_role("button", name="Log out")
def expect_loaded(self):
expect(self.page).to_have_url(re.compile(r"/dashboard"))
expect(self.welcome_heading).to_be_visible()
return self
def logout(self):
self.logout_button.click()
return self
# tests/test_auth.py
import pytest
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage
@pytest.fixture
def login_page(page):
return LoginPage(page)
@pytest.fixture
def dashboard_page(page):
return DashboardPage(page)
def test_successful_login(login_page, dashboard_page):
login_page.navigate().login("alice", "secret123")
dashboard_page.expect_loaded()
def test_failed_login(login_page):
login_page.navigate().login("alice", "wrongpassword")
login_page.expect_error("Invalid credentials")
def test_logout(login_page, dashboard_page):
login_page.navigate().login("alice", "secret123")
dashboard_page.expect_loaded().logout()
expect(login_page.page).to_have_url(re.compile(r"/login"))
The POM pattern pays off when the UI changes. When the "Login" button gets renamed "Sign in", you update one line in LoginPage rather than searching every test file.
Screenshots and Video Recording
Taking Screenshots
# Full-page screenshot
page.screenshot(path="screenshots/homepage.png", full_page=True)
# Screenshot of a specific element
page.get_by_role("main").screenshot(path="screenshots/main-content.png")
# Screenshot as bytes (useful in CI pipelines)
image_bytes = page.screenshot()
Recording Video
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context(
record_video_dir="videos/",
record_video_size={"width": 1280, "height": 720},
)
page = context.new_page()
page.goto("https://example.com")
# ... your test actions
context.close() # video is saved on context.close()
browser.close()
# Get the path of the recorded video
video_path = page.video.path()
print(f"Video saved to: {video_path}")
With pytest-playwright, enable video recording on the command line:
pytest --video on # record for failed tests only
pytest --video retain-on-failure
pytest --video off # default
Videos are saved to the test-results/ directory alongside traces and screenshots.
Playwright Traces
Traces are the most powerful debugging tool in Playwright. They capture a full timeline of your test including DOM snapshots, network requests, screenshots, and console logs. View them in the Playwright Trace Viewer.
# Enable tracing in pytest
pytest --tracing on
# View a trace
playwright show-trace test-results/my-test/trace.zip
Visual Regression Testing: Screenshot Comparisons
Visual regression testing catches unintended UI changes by comparing screenshots pixel-by-pixel against a baseline.
pytest-playwright has no built-in snapshot comparison, but playwright-pytest-snapshot and pixelmatch fill that gap. A simpler and increasingly popular choice in 2026 is integrating with pytest-image-diff:
pip install pytest-image-diff
A lightweight manual approach using Pillow:
# tests/test_visual.py
import hashlib
from pathlib import Path
from PIL import Image, ImageChops
import pytest
BASELINE_DIR = Path("tests/visual-baselines")
ACTUAL_DIR = Path("test-results/visual-actuals")
BASELINE_DIR.mkdir(parents=True, exist_ok=True)
ACTUAL_DIR.mkdir(parents=True, exist_ok=True)
def compare_screenshots(page, name: str, threshold: float = 0.01):
"""Take a screenshot and compare it against the stored baseline."""
actual_path = ACTUAL_DIR / f"{name}.png"
baseline_path = BASELINE_DIR / f"{name}.png"
page.screenshot(path=str(actual_path), full_page=True)
if not baseline_path.exists():
# First run: create the baseline
actual_path.replace(baseline_path)
pytest.skip(f"Baseline created at {baseline_path}. Re-run to compare.")
actual = Image.open(actual_path).convert("RGB")
baseline = Image.open(baseline_path).convert("RGB")
if actual.size != baseline.size:
pytest.fail(f"Image sizes differ: {actual.size} vs {baseline.size}")
diff = ImageChops.difference(actual, baseline)
pixels = list(diff.getdata())
total = len(pixels)
different = sum(1 for p in pixels if any(c > 10 for c in p))
ratio = different / total
if ratio > threshold:
pytest.fail(
f"Visual diff {ratio:.2%} exceeds threshold {threshold:.2%} for '{name}'"
)
def test_homepage_visual(page):
page.goto("https://example.com")
compare_screenshots(page, "homepage")
def test_login_page_visual(page):
page.goto("https://example.com/login")
compare_screenshots(page, "login-page")
For production use, consider Percy or Chromatic, which manage baselines, diffs, and review workflows across your team.
Network Interception: Mocking API Responses
page.route() lets you intercept, modify, or abort network requests. This is essential for:
- Testing error states that are hard to reproduce against a real API
- Speeding up tests by eliminating real network calls
- Testing offline scenarios
Mock a GET request
def test_dashboard_shows_user_data(page):
# Intercept the API call and return mock data
def handle_users(route):
route.fulfill(
status=200,
content_type="application/json",
body='[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]',
)
page.route("**/api/users", handle_users)
page.goto("https://app.example.com/dashboard")
expect(page.get_by_text("Alice")).to_be_visible()
expect(page.get_by_text("Bob")).to_be_visible()
Mock an error response
def test_dashboard_shows_error_on_api_failure(page):
page.route("**/api/users", lambda route: route.fulfill(status=500))
page.goto("https://app.example.com/dashboard")
expect(page.get_by_role("alert")).to_contain_text("Failed to load users")
Modify a real response
def test_inject_extra_user(page):
def add_user(route):
response = route.fetch()
data = response.json()
data.append({"id": 99, "name": "Injected User"})
route.fulfill(response=response, json=data)
page.route("**/api/users", add_user)
page.goto("https://app.example.com/dashboard")
expect(page.get_by_text("Injected User")).to_be_visible()
Abort requests
def test_page_loads_without_analytics(page):
# Block tracking scripts
page.route("**/*google-analytics*", lambda route: route.abort())
page.route("**/*segment.io*", lambda route: route.abort())
page.goto("https://example.com")
expect(page).to_have_title("Example Domain")
Parallel Test Execution: pytest-xdist Integration
Playwright tests are naturally slow because they launch real browsers. Running them in parallel cuts total test suite time dramatically.
pip install pytest-xdist
# Run tests across 4 worker processes
pytest -n 4
# Auto-detect CPU count
pytest -n auto
# Combine with browser selection
pytest -n auto --browser chromium
pytest-playwright is designed to work with pytest-xdist. Each worker gets its own browser instance. Browser contexts and pages are created fresh per test, so there is no shared state.
Playwright's Own Sharding (for CI)
For very large test suites, use Playwright's sharding to split tests across multiple CI machines:
# Run shard 1 of 4 (on machine 1)
pytest --shard=1/4
# Run shard 2 of 4 (on machine 2)
pytest --shard=2/4
conftest.py for Parallel-safe Fixtures
# conftest.py
import pytest
from playwright.sync_api import Browser, BrowserContext
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, tmp_path_factory):
# Give each worker its own storage state directory
return {
**browser_context_args,
"storage_state": None,
}
@pytest.fixture
def authenticated_page(page, base_url):
"""Log in once and return the page ready for authenticated actions."""
page.goto(f"{base_url}/login")
page.get_by_label("Username").fill("testuser")
page.get_by_label("Password").fill("testpass")
page.get_by_role("button", name="Login").click()
page.wait_for_url("**/dashboard")
return page
Mobile Emulation and Device Testing
Playwright ships with a registry of device descriptors covering popular phones and tablets. Mobile emulation sets the viewport, device pixel ratio, user agent, and touch support.
from playwright.sync_api import sync_playwright, devices
with sync_playwright() as p:
iphone = devices["iPhone 14"]
browser = p.webkit.launch()
context = browser.new_context(**iphone)
page = context.new_page()
page.goto("https://example.com")
page.screenshot(path="iphone14.png")
browser.close()
Available device names include: iPhone 14, iPhone 14 Pro, iPad (gen 9), Pixel 7, Galaxy S23, and many more. List all available devices:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
for name in sorted(p.devices.keys()):
print(name)
Mobile Tests in pytest
# conftest.py
import pytest
from playwright.sync_api import devices
@pytest.fixture(params=["iPhone 14", "Pixel 7"])
def mobile_page(playwright, request):
device = devices[request.param]
browser = playwright.webkit.launch() if "iPhone" in request.param else playwright.chromium.launch()
context = browser.new_context(**device)
page = context.new_page()
yield page
context.close()
browser.close()
# tests/test_mobile.py
def test_mobile_navigation(mobile_page):
mobile_page.goto("https://example.com")
# On mobile, a hamburger menu replaces the nav bar
mobile_page.get_by_role("button", name="Menu").click()
expect(mobile_page.get_by_role("navigation")).to_be_visible()
Geolocation and Permissions
context = browser.new_context(
geolocation={"longitude": -122.4194, "latitude": 37.7749},
permissions=["geolocation"],
locale="en-US",
timezone_id="America/Los_Angeles",
)
Headless vs Headed Mode, CI Configuration
Headless Mode (Default)
By default, Playwright runs browsers in headless mode — no visible window. This is correct for CI. Headless mode is faster and does not require a display server.
browser = p.chromium.launch(headless=True) # default
Headed Mode (Local Debugging)
During development, run headed to watch the browser:
browser = p.chromium.launch(headless=False, slow_mo=100)
slow_mo adds a delay (in milliseconds) between each action, making it easier to follow what Playwright is doing.
Headed Mode in pytest
pytest --headed
pytest --headed --slowmo 200
Running in CI Without a Display Server
On Linux CI runners without a display, use xvfb-run for headed mode (though headless is strongly preferred):
xvfb-run pytest --headed
Or use the Docker image which includes all dependencies:
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/playwright/python:v1.44.0-jammy pytest
GitHub Actions Workflow for Playwright Tests
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
shard: [1, 2, 3, 4]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest-playwright pytest-xdist
- name: Install Playwright browsers
run: playwright install --with-deps ${{ matrix.browser }}
- name: Run Playwright tests
run: |
pytest tests/ \
--browser ${{ matrix.browser }} \
--shard=${{ matrix.shard }}/4 \
--screenshot on \
--video retain-on-failure \
--tracing retain-on-failure \
-v
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-results-${{ matrix.browser }}-shard${{ matrix.shard }}
path: test-results/
retention-days: 7
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.browser }}-shard${{ matrix.shard }}
path: playwright-report/
retention-days: 7
Caching Browser Binaries
Browser downloads are slow. Cache them across runs:
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright browsers (if cache miss)
run: playwright install --with-deps chromium
Minimal Single-Browser Workflow
For teams that only test Chromium and want a simpler setup:
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest pytest-playwright
- run: playwright install --with-deps chromium
- run: pytest tests/ --browser chromium --screenshot on
- uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: test-results/
FAQ
Q: Do I need a running browser installed on my system? No. playwright install downloads self-contained browser builds that are independent of any system browser. Playwright manages them entirely.
Q: Can Playwright test Chrome extensions? Yes, but only in headed Chromium with a persistent context. Headless mode does not support extensions.
Q: How do I handle authentication without logging in for every test? Use browser.new_context(storage_state="auth.json") to restore a previously saved login state. Save it once in a setup fixture:
# conftest.py
@pytest.fixture(scope="session")
def auth_state(browser, base_url, tmp_path_factory):
context = browser.new_context()
page = context.new_page()
page.goto(f"{base_url}/login")
page.get_by_label("Username").fill("testuser")
page.get_by_label("Password").fill("testpass")
page.get_by_role("button", name="Login").click()
state_path = str(tmp_path_factory.mktemp("auth") / "state.json")
context.storage_state(path=state_path)
context.close()
return state_path
@pytest.fixture
def authenticated_context(browser, auth_state):
context = browser.new_context(storage_state=auth_state)
yield context
context.close()
Q: How do I test file uploads? Use set_input_files():
page.get_by_label("Upload file").set_input_files("path/to/file.pdf")
For multiple files:
page.get_by_label("Upload files").set_input_files(["file1.pdf", "file2.pdf"])
Q: How do I handle alert(), confirm(), and prompt() dialogs?
page.on("dialog", lambda dialog: dialog.accept())
page.on("dialog", lambda dialog: dialog.dismiss())
page.on("dialog", lambda dialog: dialog.accept("my input text"))
Q: How do I run tests against a local development server? Use pytest-playwright's base_url fixture and --base-url flag:
pytest --base-url http://localhost:3000
Then in tests:
def test_home(page, base_url):
page.goto(base_url)
Q: Is Playwright suitable for scraping in 2026? Yes, but check the site's robots.txt and terms of service first. For large-scale scraping, use the async API to run multiple browser contexts concurrently. Playwright handles dynamic JavaScript-rendered pages that tools like requests + BeautifulSoup cannot.
Q: How does Playwright compare to Selenium 4? Selenium 4 introduced the W3C WebDriver standard and BiDi (bidirectional) protocol support, which closes some gaps. But Playwright's auto-waiting, network interception, and out-of-the-box multi-browser support remain superior for most use cases. Selenium's main advantage is its larger ecosystem and support for every browser including legacy IE.
Sources
- Playwright Python official documentation
- pytest-playwright documentation
- Playwright Locators guide
- Playwright Network interception
- Playwright Device emulation
- Playwright Visual comparisons
- Microsoft Playwright GitHub repository
- GitHub Actions: Playwright CI
- State of JS 2024: Testing tools survey
- CNCF Incubating projects list