Home / Blog / Playwright with Python: Complete Beginner to Advanced Tutorial
Playwright with Python: Complete Beginner to Advanced Tutorial
Playwright is Microsoft's answer to the reliability problems that have plagued Selenium for years. It has built-in auto-waiting, cross-browser support, native API testing capabilities, and a Python API that feels natural to write.
If you are starting UI automation in 2026, Playwright with Python is the recommended choice for new learners.
Why Playwright Over Selenium
| Feature | Playwright | Selenium |
|---|---|---|
| Auto-waiting | Built-in | Manual (WebDriverWait) |
| Browser support | Chrome, Firefox, Safari, Edge | Chrome, Firefox, Safari, Edge |
| API testing | Built-in (request context) | Not built-in |
| Test isolation | Automatic (browser contexts) | Manual setup |
| Speed | Faster | Slower |
| Installation | Single pip install | Separate driver setup |
| Learning curve | Gentle | Steeper for beginners |
Selenium is still heavily used in enterprise and has more job listings by volume. But for learning automation, Playwright is significantly less frustrating.
Installation and Setup
# Create project folder and virtual environment
mkdir playwright-qa
cd playwright-qa
python -m venv venv
source venv/bin/activate # Mac/Linux
# venv\Scripts\activate # Windows
# Install Playwright and Pytest plugin
pip install playwright pytest-playwright
# Install browser binaries
playwright install chromium firefox webkitVerify:
pytest --version
playwright --versionCreate a requirements.txt:
playwright==1.50.0
pytest-playwright==0.5.0
pytest==8.0.0Project Structure
playwright-qa/
├── pages/
│ ├── __init__.py
│ ├── login_page.py
│ ├── product_page.py
│ └── checkout_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ ├── test_products.py
│ └── test_api.py
├── conftest.py ← Fixtures live here
├── pytest.ini ← Pytest configuration
└── requirements.txtFirst Test — No Page Objects Yet
Before adding structure, understand the basics:
# tests/test_first.py
from playwright.sync_api import Page, expect
def test_page_title(page: Page):
page.goto("https://www.saucedemo.com")
expect(page).to_have_title("Swag Labs")
def test_login_works(page: Page):
page.goto("https://www.saucedemo.com")
page.get_by_placeholder("Username").fill("standard_user")
page.get_by_placeholder("Password").fill("secret_sauce")
page.get_by_role("button", name="Login").click()
expect(page).to_have_url("https://www.saucedemo.com/inventory.html")Run with:
pytest tests/test_first.py -vThe page fixture is provided automatically by pytest-playwright. No setup needed.
Playwright Locators — The Right Way
Playwright recommends role-based and user-facing locators over CSS/XPath. These are more resilient to DOM changes.
# PREFERRED — role-based locators
page.get_by_role("button", name="Add to cart")
page.get_by_role("link", name="Products")
page.get_by_label("Email address")
page.get_by_placeholder("Search products")
page.get_by_text("Order confirmed")
# ALSO GOOD — test IDs (if your team adds them)
page.get_by_test_id("login-button")
# ACCEPTABLE — CSS selectors for complex cases
page.locator(".inventory_item .btn_primary")
page.locator("[data-test='error']")
# AVOID — fragile XPath
page.locator("//button[contains(@class,'btn')]")Key Assertions with expect()
from playwright.sync_api import expect
# URL
expect(page).to_have_url("https://example.com/dashboard")
expect(page).to_have_url(re.compile(r"/inventory"))
# Title
expect(page).to_have_title("Products | My App")
# Element visibility
expect(page.get_by_text("Welcome back")).to_be_visible()
expect(page.locator(".error-banner")).to_be_hidden()
# Text content
expect(page.locator("h1")).to_have_text("Products")
expect(page.locator(".price")).to_contain_text("$")
# Count
expect(page.locator(".product-card")).to_have_count(6)
# Input value
expect(page.get_by_label("Email")).to_have_value("test@example.com")
# Enabled/disabled
expect(page.get_by_role("button", name="Submit")).to_be_enabled()
expect(page.get_by_role("button", name="Submit")).to_be_disabled()expect() has built-in retry logic — it will keep checking the assertion until it passes or times out (default: 5 seconds). This is Playwright's auto-waiting in action.
conftest.py — Fixtures and Configuration
# conftest.py
import pytest
from playwright.sync_api import Browser, BrowserContext, 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://www.saucedemo.com",
}
@pytest.fixture
def logged_in_page(page: Page) -> Page:
"""Returns a Page already logged in to saucedemo."""
page.goto("/")
page.get_by_placeholder("Username").fill("standard_user")
page.get_by_placeholder("Password").fill("secret_sauce")
page.get_by_role("button", name="Login").click()
page.wait_for_url("**/inventory.html")
return pagePage Object Model
# 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_placeholder("Username")
self.password_input = page.get_by_placeholder("Password")
self.login_button = page.get_by_role("button", name="Login")
self.error_message = page.locator("[data-test='error']")
def navigate(self):
self.page.goto("https://www.saucedemo.com")
def login(self, username: str, password: str):
self.username_input.fill(username)
self.password_input.fill(password)
self.login_button.click()
def get_error_text(self) -> str:
return self.error_message.inner_text()
def expect_error_visible(self):
expect(self.error_message).to_be_visible()# pages/inventory_page.py
from playwright.sync_api import Page, expect
from typing import List
class InventoryPage:
def __init__(self, page: Page):
self.page = page
self.page_title = page.locator(".title")
self.product_items = page.locator(".inventory_item")
self.sort_dropdown = page.locator("[data-test='product-sort-container']")
def is_on_inventory(self) -> bool:
return "/inventory" in self.page.url
def get_product_count(self) -> int:
return self.product_items.count()
def get_all_product_names(self) -> List[str]:
return self.page.locator(".inventory_item_name").all_inner_texts()
def add_first_product_to_cart(self):
self.page.locator(".btn_inventory").first.click()
def sort_by(self, option: str):
self.sort_dropdown.select_option(option)# tests/test_login.py
import pytest
from playwright.sync_api import Page
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage
class TestLogin:
def test_valid_login_reaches_inventory(self, page: Page):
login = LoginPage(page)
login.navigate()
login.login("standard_user", "secret_sauce")
inventory = InventoryPage(page)
assert inventory.is_on_inventory()
def test_invalid_password_shows_error(self, page: Page):
login = LoginPage(page)
login.navigate()
login.login("standard_user", "bad_password")
login.expect_error_visible()
assert "Username and password do not match" in login.get_error_text()
@pytest.mark.parametrize("username,password,expected_error", [
("", "", "Username is required"),
("standard_user", "", "Password is required"),
("locked_out_user", "secret_sauce", "locked out"),
])
def test_login_error_scenarios(self, page: Page, username, password, expected_error):
login = LoginPage(page)
login.navigate()
login.login(username, password)
login.expect_error_visible()
assert expected_error in login.get_error_text()API Testing with Playwright Request Context
Playwright includes a built-in HTTP client — no need for requests library for API tests.
# tests/test_api.py
import pytest
from playwright.sync_api import APIRequestContext, Playwright
@pytest.fixture(scope="session")
def api_context(playwright: Playwright) -> APIRequestContext:
context = playwright.request.new_context(
base_url="https://jsonplaceholder.typicode.com"
)
yield context
context.dispose()
class TestUsersAPI:
def test_get_user_returns_200(self, api_context: APIRequestContext):
response = api_context.get("/users/1")
assert response.status == 200
def test_get_user_has_name(self, api_context: APIRequestContext):
response = api_context.get("/users/1")
data = response.json()
assert "name" in data
assert len(data["name"]) > 0
def test_create_post(self, api_context: APIRequestContext):
payload = {
"title": "Test Post",
"body": "This is a test post body.",
"userId": 1
}
response = api_context.post("/posts", data=payload)
assert response.status == 201
created = response.json()
assert created["title"] == payload["title"]
assert "id" in created
def test_nonexistent_user_returns_404(self, api_context: APIRequestContext):
response = api_context.get("/users/9999")
assert response.status == 404Advanced Features
Screenshots on Test Failure
# conftest.py — capture screenshot when a test fails
import pytest
from playwright.sync_api import Page
@pytest.fixture(autouse=True)
def screenshot_on_failure(page: Page, request):
yield
if request.node.rep_call.failed:
page.screenshot(path=f"screenshots/{request.node.name}.png")Add to conftest.py to capture failure evidence automatically.
Tracing — Full Test Replay
# conftest.py
@pytest.fixture(autouse=True)
def trace_on_failure(context, request):
context.tracing.start(screenshots=True, snapshots=True)
yield
if request.node.rep_call.failed:
context.tracing.stop(path=f"traces/{request.node.name}.zip")
else:
context.tracing.stop()Open a trace file to replay the full test execution:
playwright show-trace traces/test_login.zipHandling Multiple Tabs
def test_link_opens_new_tab(page: Page):
with page.expect_popup() as popup_info:
page.get_by_role("link", name="Open in new tab").click()
new_tab = popup_info.value
new_tab.wait_for_load_state()
assert "expected-page" in new_tab.urlIntercepting Network Requests
def test_with_mocked_api(page: Page):
# Mock the API response
page.route("**/api/products", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='[{"id": 1, "name": "Mocked Product", "price": 999}]'
))
page.goto("https://your-app.com/products")
# Now the page loads with mocked data — test without backend dependencypytest.ini Configuration
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Default Playwright options
addopts = --browser=chromium --headed=falseGitHub Actions CI
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Install Playwright browsers
run: playwright install chromium --with-deps
- name: Run tests
run: pytest tests/ -v --html=report.html --self-contained-html
- name: Upload test report
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: report.htmlSummary
You now have a complete Playwright + Python framework with:
- Page Object Model for maintainable test code
- Parametrized tests for data-driven scenarios
- Built-in API testing via request context
- Screenshots and tracing for failure investigation
- GitHub Actions CI running in headless mode
Next steps:
- Add 10 more tests covering product search, cart operations, and checkout
- Add the
allure-pytestpackage for richer HTML reports - Explore
playwright codegen— it generates Python test code by recording your browser actions
Recommended Resource
Automation Testing Scenarios Pack
High-quality automation scenarios for UI, API, and microservices systems.
Related Posts
Behavioral Interview Questions for QA Engineers (With Sample Answers)
Behavioral interview questions specifically for QA and SDET roles — with the STAR method explained and sample answers that actually sound human.
Read article →QA Interview Preparation Checklist: Everything You Need to Know
A complete QA interview preparation checklist — what to study, what to practice, and how to approach each round of a QA or SDET interview.
Read article →35 Playwright Interview Questions (With Answers)
Playwright interview questions and answers for QA and SDET roles — covering setup, locators, waits, fixtures, API testing, and debugging.
Read article →