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.
In this guide9 sections
- Create a reproducible Python project
- Configure the base URL and context
- Create test-owned data through the API
- Write locators around user behavior
- Keep assertions web-first
- Wait for outcomes, not elapsed time
- Model authentication as a fixture boundary
- Structure page abstractions by responsibility
- Preserve traces and useful diagnostics
- 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:
python -m venv .venv
source .venv/bin/activate
python -m pip install pytest pytest-playwright
playwright installBrowser 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:
[pytest]
testpaths = tests
addopts = -ra --strict-markers
markers =
smoke: release-blocking browser checks
destructive: tests that modify shared environment stateDo 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.
# 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.
# 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.
# 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:
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:
@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.
# 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:
pytest tests/test_inventory.py --headed --browser chromium -qIn CI, enable artifacts through plugin options:
pytest tests --browser chromium \
--tracing retain-on-failure \
--screenshot only-on-failure \
--output test-resultsA 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.
- 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-resultsWhen 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.
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.
- 01Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
- 02Playwright best practices
Microsoft
Official guidance for resilient tests, isolation, and user-facing locators.
- 03
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.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 02
Playwright API Testing: Validate APIs Inside Your E2E Suite
Playwright API testing guide covering request contexts, setup flows, assertions, authentication, fixtures, UI strategy, and CI-ready checks.
GUIDE 03
How to Take Screenshots in Playwright
Learn how to take screenshots in Playwright for full page, element, and failure captures, plus visual checks and practical CI debugging tips.
GUIDE 04
How to Run Tests in Parallel with Playwright
Learn how to run tests in parallel with Playwright: workers, sharding, isolation, CI config, flakiness fixes, and a practical checklist for faster suites.