Home / Blog / Playwright with Python: Complete Beginner to Advanced Tutorial

Playwright

Playwright with Python: Complete Beginner to Advanced Tutorial

QA Knowledge Hub·2026-04-06·6 min read

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

FeaturePlaywrightSelenium
Auto-waitingBuilt-inManual (WebDriverWait)
Browser supportChrome, Firefox, Safari, EdgeChrome, Firefox, Safari, Edge
API testingBuilt-in (request context)Not built-in
Test isolationAutomatic (browser contexts)Manual setup
SpeedFasterSlower
InstallationSingle pip installSeparate driver setup
Learning curveGentleSteeper 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 webkit

Verify:

pytest --version
playwright --version

Create a requirements.txt:

playwright==1.50.0
pytest-playwright==0.5.0
pytest==8.0.0

Project 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.txt

First 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 -v

The 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 page

Page 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 == 404

Advanced 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.zip

Handling 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.url

Intercepting 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 dependency

pytest.ini Configuration

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# Default Playwright options
addopts = --browser=chromium --headed=false

GitHub 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.html

Summary

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:

  1. Add 10 more tests covering product search, cart operations, and checkout
  2. Add the allure-pytest package for richer HTML reports
  3. 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.

1299Get This Guide →

Related Posts

📝
Interview Prep
Apr 2026·12 min read

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 →
📝
Interview Prep
Apr 2026·12 min read

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 →
📝
Interview Prep
Apr 2026·12 min read

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 →