Install is done, sync API picked, virtualenv active. Time to write a test that actually drives a browser. This lesson walks through a pytest-playwright test from the imports down to the run command — the fixture mechanics, the type hints, the assertion library, the class-based grouping pattern — and ends with a real product-search spec you'd be proud to commit. By the end you'll know exactly what every part of a test file does and why it's there.
The smallest useful test
Create tests/test_home.py:
from playwright.sync_api import Page, expect
def test_homepage_has_title(page: Page):
page.goto("/")
expect(page).to_have_title("My App")
def test_homepage_has_welcome_message(page: Page):
page.goto("/")
heading = page.get_by_role("heading", level=1)
expect(heading).to_contain_text("Welcome")Two tests, twelve lines, no boilerplate. Run them with:
pytest tests/test_home.py -vpytest discovers both functions (they start with test_), pytest-playwright provides each with a fresh Page, and the tests pass or fail individually. That's the whole loop.
Reading the imports
from playwright.sync_api import Page, expectTwo things from one module:
Page— the type of the browser tab. You don't need to import it; the test would still run if you wrotedef test_(page):. But adding the type hintpage: Pagelights up VS Code's autocomplete on every method (page.goto,page.get_by_role,page.locator, etc.) and gives Pylance/mypy something to type-check against. In a project that grows past ten tests, type hints are pure upside.expect— Playwright's assertion library. Sameexpectyou used in the Playwright TypeScript course, in snake_case form:to_have_title,to_be_visible,to_contain_text. Auto-retries until the condition holds or the assertion timeout fires (5 seconds default).
You won't import pytest, Browser, or BrowserContext for most tests. The page fixture handles all of that.
The function name and the page fixture
def test_homepage_has_title(page: Page):Two pytest rules and one Playwright convention:
- The function name starts with
test_. pytest's discovery rule.def homepage_test(...)would silently not run. - The function is a plain
def, notasync def. pytest-playwright'spagefixture is sync — pair it withasync defand you'll get a fixture-mismatch error. - The parameter is named
page. Notbrowser_page, notpg— exactlypage. pytest-playwright registers fixtures by name, so the parameter name is the wiring. Changing it doesn't rename the fixture; it breaks the lookup.
What page actually is: a fresh Page object backed by a fresh BrowserContext per test. State doesn't bleed across tests — every test starts with no cookies, no localStorage, no shared history.
page.goto() — navigation, with a base URL
page.goto("/")The empty path is resolved against the base_url from pytest.ini:
[pytest]
base_url = https://www.saucedemo.comSo page.goto("/") hits https://www.saucedemo.com/. Calling page.goto("/inventory") hits https://www.saucedemo.com/inventory. Same idea as baseURL in playwright.config.ts from the TypeScript course — you keep tests environment-agnostic and switch base URLs by setting base_url per environment.
page.goto returns a Response object you can inspect (status code, headers) but in 90% of tests you ignore the return value.
expect(...) — auto-retrying assertions
expect(page).to_have_title("My App")
expect(heading).to_contain_text("Welcome")Two flavours of expect:
expect(page)— page-level assertions:to_have_title,to_have_url.expect(locator)— locator-level assertions:to_be_visible,to_have_text,to_have_count, etc.
Both auto-retry. After clicking a button that triggers an async render, you can write the assertion immediately — no time.sleep, no manual wait:
page.get_by_role("button", name="Submit").click()
expect(page.get_by_text("Order confirmed")).to_be_visible()
# Playwright keeps re-querying the DOM until the toast appears
# or 5 seconds pass.This single behaviour is why most Playwright Python tests have no explicit waits. The framework polls for you.
In the TypeScript course you wrote await expect(...).toBeVisible(). In Python sync, the same call is expect(...).to_be_visible() — no await, snake_case method name. Same retry semantics.
Grouping tests with classes
For more than a handful of tests on one feature, group them into a class:
from playwright.sync_api import Page, expect
class TestProductSearch:
def test_displays_products(self, page: Page):
page.goto("/products")
products = page.get_by_test_id("product-card")
expect(products).to_have_count(10)
def test_filters_by_category(self, page: Page):
page.goto("/products")
page.get_by_label("Category").select_option("Electronics")
first_product = page.get_by_test_id("product-card").first
expect(first_product).to_contain_text("Electronics")
def test_searches_by_name(self, page: Page):
page.goto("/products")
page.get_by_placeholder("Search products").fill("Laptop")
page.get_by_role("button", name="Search").click()
expect(page.get_by_test_id("product-card")).to_have_count(3)The pytest rules for classes:
- Class name starts with
Test(capital T, no underscore). - Method names start with
test_. - Methods take
selffirst, then the fixtures:def test_(self, page: Page). - No
__init__method. pytest forbids it on test classes — fixtures replace constructor injection.
Classes are pure organisation — pytest treats each method as an independent test, runs them in declaration order, and gives each its own fresh page. There's no shared state between methods unless you explicitly opt in via fixtures.
The TypeScript equivalent is test.describe("Product search", () => { test(...); test(...); }). Different syntax, same grouping intent.
All the fixtures pytest-playwright gives you
You've met page. The plugin provides four more out of the box:
| Fixture | Type | Scope | When you'd use it |
|---|---|---|---|
page | Page | function | The default for every test — a fresh tab. |
context | BrowserContext | function | When you need cookies, storage_state, or multiple pages in one test. |
browser | Browser | session | Rare — when you want to manage contexts manually. |
browser_name | str | session | The current browser ID — "chromium", "firefox", or "webkit". |
playwright | Playwright | session | The root Playwright object. Used to access playwright.devices. |
Just add the fixture name as a parameter. Want to know which browser the test is running in?
def test_browser_specific_quirk(page: Page, browser_name: str):
page.goto("/")
if browser_name == "webkit":
# Safari-only assertion
expect(page.get_by_test_id("safari-banner")).to_be_visible()You'll add custom fixtures of your own (logged-in pages, seeded data) in chapter 3.
How the test actually runs — the lifecycle
The browser launch happens once per worker (session-scoped); contexts and pages are rebuilt for every test (function-scoped). That's why pytest-playwright is fast — you pay the browser startup cost once, then each test gets a clean profile in milliseconds.
Snake_case naming — the only TS-vs-Python difference you'll feel
Almost every Playwright method changes case from camelCase (TS) to snake_case (Python):
page.getByRole(...)→page.get_by_role(...)page.getByLabel(...)→page.get_by_label(...)page.getByTestId(...)→page.get_by_test_id(...)expect(locator).toHaveText(...)→expect(locator).to_have_text(...)expect(locator).toBeVisible(...)→expect(locator).to_be_visible(...)page.setViewportSize({width, height})→page.set_viewport_size({"width": ..., "height": ...})
The behaviour is identical — Playwright uses the same engine and the same DOM queries. Only the name changes. If you're cross-training between the TypeScript course and this one, internalise this conversion once and the rest of the API maps 1:1.
⚠️ Common mistakes
- Mistyping the parameter name as
pgorbrowser_pageinstead ofpage. pytest-playwright matches fixtures by parameter name. The first time pytest reportsfixture 'pg' not found, this is why. The fixture is exactlypage— match it letter for letter. - Awaiting a sync method.
await page.get_by_role(...).click()raisesSyntaxError: 'await' outside async functionbecausedef test_is notasync def. The whole point of pytest-playwright sync is noawait. If a TypeScript example you're translating hasawait, drop it; the Python sync version does the wait internally. - Using
assert page.get_by_test_id("toast").text_content() == "Saved"instead ofexpect(...).to_have_text("Saved"). The first form snapshots the DOM once and skips Playwright's auto-retry — fine when the toast renders synchronously, flaky when it doesn't. Reach forexpect(...)whenever the value depends on async rendering. We'll cover this in depth in chapter 2's assertions lesson.
🎯 Practice task
Build a real, multi-test spec. 25-30 minutes.
-
Set
base_url = https://www.saucedemo.cominpytest.ini. Make sure--headed --browser chromiumis inaddoptsso you can see the test. -
Create
tests/test_login.pywith three tests grouped in a class:from playwright.sync_api import Page, expect class TestSauceDemoLogin: def test_login_page_renders(self, page: Page): page.goto("/") expect(page.get_by_placeholder("Username")).to_be_visible() expect(page.get_by_placeholder("Password")).to_be_visible() expect(page.get_by_role("button", name="Login")).to_be_visible() def test_login_with_valid_credentials(self, page: Page): 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() expect(page).to_have_url("/inventory.html") def test_login_with_invalid_credentials_shows_error(self, page: Page): page.goto("/") page.get_by_placeholder("Username").fill("standard_user") page.get_by_placeholder("Password").fill("wrong_password") page.get_by_role("button", name="Login").click() expect(page.get_by_test_id("error")).to_contain_text("Username and password do not match") -
Run with
pytest tests/test_login.py -v. Three lines of green confirm all tests passed. -
Confirm test isolation. Reorder the methods in the class so the invalid-credentials test runs first. Re-run. Each test still passes — the failed login from one test doesn't bleed into the next, because every test gets a fresh
page(and therefore a fresh login state). -
Use
browser_name. Add a fourth test that asserts the login succeeds on every browser:def test_login_works_in_all_browsers(self, page: Page, browser_name: str): 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() expect(page).to_have_url("/inventory.html") print(f"\nLogin verified on {browser_name}")Run with
pytest tests/test_login.py --browser chromium --browser firefox --browser webkit -v -s. Twelve test results (three browsers × four tests) and one printedbrowser_nameper browser per test. -
Stretch: drop
--headedfrompytest.iniand re-run. The whole suite executes headlessly in seconds — the same shape you'd run in CI. Add--workers 4(after installingpytest-xdistlater, or just imagine it for now); chapter 7 covers parallel execution properly.
You now have the full anatomy of a pytest-playwright test. The next lesson covers Playwright Inspector and Codegen for Python — the two tools that turn a manual click-through into a working test in seconds.