PRACTICAL GUIDE / playwright python tutorial

Playwright Python Tutorial: Fast Browser Tests with Pytest

Playwright Python tutorial covering setup, pytest fixtures, locators, assertions, tracing, API setup, CI, reports, and maintainable browser tests.

By The Testing AcademyUpdated July 10, 202610 min read
All field guides
In this guide9 sections
  1. Create a reproducible Python project
  2. Configure the base URL and context
  3. Create test-owned data through the API
  4. Write locators around user behavior
  5. Keep assertions web-first
  6. Wait for outcomes, not elapsed time
  7. Model authentication as a fixture boundary
  8. Structure page abstractions by responsibility
  9. Preserve traces and useful diagnostics
  10. Run a deliberate CI matrix

What you will learn

  • Create a reproducible Python project
  • Configure the base URL and context
  • Create test-owned data through the API
  • Write locators around user behavior

A test that logs in successfully on a developer laptop but fails after a React rerender in CI usually has two design problems: it located a transient DOM node, and its setup depends on state the test does not own. Playwright's Python binding can reduce both problems, but auto-waiting alone will not turn an ambiguous workflow into a reliable test.

This project uses pytest to test an inventory application. Each test receives an isolated browser context, an API-created warehouse item, user-facing locators, and trace evidence on failure. The result is a small pattern that can be copied into a real Python repository.

Create a reproducible Python project

Use a virtual environment and keep test dependencies in a locked project file or requirements input managed by the repository. A minimal local setup is:

Shell
python -m venv .venv
source .venv/bin/activate
python -m pip install pytest pytest-playwright
playwright install

Browser installation is a separate step from installing the Python package. CI images also need the browser binaries and their operating-system dependencies. Use playwright install --with-deps chromium on a supported Linux runner, or a maintained Playwright container that matches the project's package expectations.

Configure discovery and useful defaults in pytest.ini:

INI
[pytest]
testpaths = tests
addopts = -ra --strict-markers
markers =
    smoke: release-blocking browser checks
    destructive: tests that modify shared environment state

Do not put a global retry plugin into the first setup. A rerun can hide an isolation or synchronization defect. Add reruns only after the team classifies which infrastructure failures are truly transient.

Configure the base URL and context

The pytest plugin provides browser, context, and page fixtures. Override fixture options in conftest.py rather than repeating viewport or locale configuration in every test.

Python
# tests/conftest.py
import os
import pytest


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        "base_url": os.getenv("APP_URL", "http://127.0.0.1:8000"),
        "locale": "en-US",
        "viewport": {"width": 1440, "height": 900},
    }

The normal page fixture is created in a fresh context for each test, which isolates cookies, local storage, and session storage. Backend records remain shared. A clean browser context cannot prevent two workers from editing the same inventory item.

Keep secrets outside pytest.ini. If the suite needs credentials, load them from environment variables and raise a clear error during fixture setup when they are missing.

Create test-owned data through the API

The inventory test should evaluate editing, not onboarding. Create an item directly through a protected test API, yield only the fields the test needs, and remove it afterward.

Python
# tests/conftest.py
from dataclasses import dataclass
from uuid import uuid4
from playwright.sync_api import Playwright, expect


@dataclass(frozen=True)
class InventoryItem:
    item_id: str
    sku: str


@pytest.fixture
def inventory_item(playwright: Playwright) -> InventoryItem:
    base_url = os.getenv("APP_URL", "http://127.0.0.1:8000")
    token = os.environ["TEST_API_TOKEN"]
    client = playwright.request.new_context(
        base_url=base_url,
        extra_http_headers={"Authorization": f"Bearer {token}"},
    )
    sku = f"PW-{uuid4().hex[:10]}"

    response = client.post(
        "/test-support/items",
        data={"sku": sku, "name": "Field notebook", "quantity": 4},
    )
    assert response.status == 201, response.text()
    body = response.json()

    yield InventoryItem(item_id=body["id"], sku=sku)

    cleanup = client.delete(f"/test-support/items/{body['id']}")
    assert cleanup.status in (204, 404), cleanup.text()
    client.dispose()

The UUID makes parallel data collisions unlikely. The API must return only after the record is readable. If it is eventually consistent, poll a read endpoint with a deadline in the fixture instead of sleeping for a fixed duration.

Cleanup accepts 404 because the application may delete the record during the test. Do not adopt that rule if 404 would indicate a real cleanup bug in your system. Teardown policy is part of the product contract.

Write locators around user behavior

Use roles, labels, and stable test IDs. Playwright locators resolve against the current DOM when an action or assertion runs, so they tolerate ordinary rerendering better than a cached element handle.

Python
# tests/test_inventory.py
from playwright.sync_api import Page, expect


def test_manager_updates_stock(page: Page, inventory_item):
    page.goto(f"/inventory/{inventory_item.item_id}")

    expect(
        page.get_by_role("heading", name="Field notebook")
    ).to_be_visible()

    quantity = page.get_by_label("Quantity on hand")
    quantity.fill("7")
    page.get_by_role("button", name="Save inventory").click()

    expect(page.get_by_role("status")).to_have_text("Inventory saved")
    expect(quantity).to_have_value("7")

The status assertion is stronger than checking that the button disappeared. The input value confirms the rendered state, but it does not prove backend persistence. For a high-risk update, add a read API assertion or reload the page and verify the value survives.

Strict locator behavior is useful. If get_by_role("button", name="Save inventory") matches two buttons, fix the accessible name or narrow the locator to a meaningful region. Using .first merely silences a product ambiguity.

Keep assertions web-first

Prefer Playwright's expect(locator) assertions over reading a value and comparing it immediately with plain Python. A web-first assertion resolves the locator and retries while the interface reaches the expected state. An immediate assert locator.text_content() == ... samples once and can race a valid render.

Plain assertions still belong around API responses, calculated test data, and values that are already stable. The distinction is not style. It determines whether the framework understands that it should retry a browser observation.

Use filter(has_text=...) or a nested locator to identify a row, then assert on a specific cell. Avoid a page-wide text search that can pass because the same SKU appears in a notification or hidden template. If the table is virtualized, scroll through the product's supported controls rather than increasing the element timeout.

Wait for outcomes, not elapsed time

Playwright waits for actionability before clicking and retries web-first assertions. It does not know that a background import, job, or websocket event has completed unless the UI exposes a signal.

For a filtered inventory table, wait on the response and visible row together:

Python
with page.expect_response(
    lambda response: "/api/items?query=" in response.url
    and response.status == 200
):
    page.get_by_role("searchbox", name="Filter inventory").fill(
        inventory_item.sku
    )

row = page.get_by_role("row").filter(has_text=inventory_item.sku)
expect(row).to_have_count(1)
expect(row).to_contain_text("Field notebook")

The response proves the search request completed, while the row assertion proves the UI consumed it. Waiting only for the response can race with rendering. Waiting only for the row can conceal a cached response when the test is meant to cover the API integration.

Avoid page.wait_for_timeout() in regression tests. It always wastes time when the app is fast and still fails when the app is slower than the chosen delay.

Network interception should be reserved for a clear boundary. A route stub can create a deterministic empty, error, or slow response for a UI-state test. It cannot prove compatibility with the deployed inventory API. Keep stubbed cases named separately from integration cases and assert that the intended route was actually exercised.

Websocket and server-sent event workflows need an application-level signal. A page may have no final HTTP response to await. Expose a visible sync status, a row update, or a supported status API. Listening to private framework events or internal client state binds the test to implementation and makes refactoring expensive.

Model authentication as a fixture boundary

Repeated UI login makes every test depend on the login page. For tests that do not evaluate login, create authenticated storage state once through a supported API or setup project, then use it to create contexts.

A simple per-test fixture can log in by API and install the returned cookie:

Python
@pytest.fixture
def manager_page(browser, playwright: Playwright):
    token = os.environ["TEST_API_TOKEN"]
    base_url = os.getenv("APP_URL", "http://127.0.0.1:8000")
    api = playwright.request.new_context(
        base_url=base_url,
        extra_http_headers={"Authorization": f"Bearer {token}"},
    )
    response = api.post("/test-support/sessions", data={"role": "manager"})
    assert response.status == 201
    session = response.json()

    context = browser.new_context(base_url=base_url)
    context.add_cookies([session["cookie"]])
    page = context.new_page()
    yield page

    context.close()
    api.dispose()

The cookie object must contain the fields Playwright requires, such as name, value, and a valid url or domain/path combination. Match the real application contract. Keep one separate test that performs login through the browser so the login journey is still protected.

Session reuse has a security cost. Storage state and cookies are credentials, so keep them in an ignored artifact directory, restrict CI access, and give them short lifetimes. If a test changes password, locale, or permissions, it should not share a session file with unrelated tests.

Parameterize roles through fixtures only when the same behavior genuinely applies. A matrix of every test against every role grows quickly and often produces redundant coverage. For inventory, verify that a manager can edit, a viewer cannot see the edit control, and an unrelated warehouse cannot access the record. These cases protect distinct authorization rules.

Structure page abstractions by responsibility

Do not create a page object for every route before patterns emerge. When inventory actions repeat, wrap the behavior and locators without hiding assertions unrelated to that component.

Python
# tests/pages/inventory_page.py
from playwright.sync_api import Page, expect


class InventoryPage:
    def __init__(self, page: Page):
        self.page = page
        self.quantity = page.get_by_label("Quantity on hand")
        self.save = page.get_by_role("button", name="Save inventory")

    def open(self, item_id: str) -> None:
        self.page.goto(f"/inventory/{item_id}")

    def set_quantity(self, value: int) -> None:
        self.quantity.fill(str(value))
        self.save.click()
        expect(self.page.get_by_role("status")).to_have_text("Inventory saved")

The save confirmation belongs with the action because callers should not proceed until saving completes. Business-specific expectations, such as reorder warnings, should remain in the test. Avoid a base page class full of unrelated generic wrappers around click and fill; it removes Playwright's readable diagnostics.

Preserve traces and useful diagnostics

Run locally with headed mode or a specific browser when investigating:

Shell
pytest tests/test_inventory.py --headed --browser chromium -q

In CI, enable artifacts through plugin options:

Shell
pytest tests --browser chromium \
  --tracing retain-on-failure \
  --screenshot only-on-failure \
  --output test-results

A trace can show actions, network activity, console messages, and DOM snapshots around failure. Publish the test-results directory even when pytest exits nonzero. Do not log authentication cookies or attach unredacted API responses.

Use page.pause() only for interactive local diagnosis. Committing it will stop unattended runs. For application console failures, register a listener in a targeted fixture and attach messages to the failing test rather than failing on every harmless warning from third-party scripts.

Run a deliberate CI matrix

Start pull requests with one browser and the small smoke marker. Add other supported engines after the tests are stable and when that coverage reflects the release policy.

YAML
- name: Install Python dependencies
  run: python -m pip install -r requirements.txt

- name: Install browser
  run: playwright install --with-deps chromium

- name: Run browser smoke tests
  run: pytest -m smoke --browser chromium --tracing retain-on-failure
  env:
    APP_URL: ${{ vars.TEST_APP_URL }}
    TEST_API_TOKEN: ${{ secrets.TEST_API_TOKEN }}

- name: Upload failure evidence
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-results
    path: test-results

When using pytest parallelization, validate that fixtures and records are worker-safe before increasing worker count. Browser contexts isolate client state, not server capacity. A reliable Playwright Python suite combines the binding's locator and context model with disciplined data ownership, observable readiness, bounded fixtures, and artifacts that let the next engineer diagnose a failure without guessing.

Keep a small browser compatibility matrix tied to product support. Running every spec in every engine may be justified for a public component library but wasteful for an internal console with one managed browser. A practical split runs critical inventory journeys across required engines and broader feature coverage on the primary engine.

Measure fixture setup, browser actions, and backend waits separately when runtime grows. Moving setup to a session fixture can make a graph look faster while introducing shared mutable state. Optimize repeated immutable work first, then shard independent tests. Preserve worker IDs and generated record IDs in the report so concurrency failures remain traceable.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 10, 2026 / Reviewed July 10, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Playwright documentation

    Microsoft

    Canonical API, locator, fixture, browser, and test-runner behavior.

  2. 02
    Playwright best practices

    Microsoft

    Official guidance for resilient tests, isolation, and user-facing locators.

  3. 03
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

Is Playwright Python good for test automation?

Yes. Playwright Python is a strong option for teams that like Python but want modern browser automation with auto waiting, browser contexts, tracing, and reliable locators. It works well for E2E tests, smoke tests, visual evidence, and flows that need API setup.

Do I need pytest for Playwright Python?

You can use Playwright without pytest, but pytest is the practical default for organized automation. The pytest plugin gives fixtures such as page, browser, and context, plus familiar test discovery, parametrization, and reporting patterns.

How is Playwright different from Selenium Python?

Playwright has built in auto waiting, browser contexts, tracing, and a locator model designed for modern web apps. Selenium is broader and older, with deep grid support and many language ecosystems. Both can be useful, but Playwright often gives faster feedback for new web automation.

Can Playwright Python test multiple browsers?

Yes. Playwright supports Chromium, Firefox, and WebKit. The pytest plugin can run tests across configured browsers. Cross browser execution is valuable, but begin with one browser until tests are stable, then expand coverage deliberately.

What should I avoid in Playwright Python?

Avoid hard sleeps, brittle CSS chains, testing internal implementation details, and long tests that mix setup with multiple assertions. Use locators by role, label, and test id when possible. Keep API setup separate from the UI behavior you are verifying.