Writing Your First Test with pytest-playwright

8 min read

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

pytest 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, expect

Two 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 wrote def test_(page):. But adding the type hint page: Page lights 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. Same expect you 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:

  1. The function name starts with test_. pytest's discovery rule. def homepage_test(...) would silently not run.
  2. The function is a plain def, not async def. pytest-playwright's page fixture is sync — pair it with async def and you'll get a fixture-mismatch error.
  3. The parameter is named page. Not browser_page, not pg — exactly page. 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.com

So 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 self first, 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:

FixtureTypeScopeWhen you'd use it
pagePagefunctionThe default for every test — a fresh tab.
contextBrowserContextfunctionWhen you need cookies, storage_state, or multiple pages in one test.
browserBrowsersessionRare — when you want to manage contexts manually.
browser_namestrsessionThe current browser ID — "chromium", "firefox", or "webkit".
playwrightPlaywrightsessionThe 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 pg or browser_page instead of page. pytest-playwright matches fixtures by parameter name. The first time pytest reports fixture 'pg' not found, this is why. The fixture is exactly page — match it letter for letter.
  • Awaiting a sync method. await page.get_by_role(...).click() raises SyntaxError: 'await' outside async function because def test_ is not async def. The whole point of pytest-playwright sync is no await. If a TypeScript example you're translating has await, drop it; the Python sync version does the wait internally.
  • Using assert page.get_by_test_id("toast").text_content() == "Saved" instead of expect(...).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 for expect(...) 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.

  1. Set base_url = https://www.saucedemo.com in pytest.ini. Make sure --headed --browser chromium is in addopts so you can see the test.

  2. Create tests/test_login.py with 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")
  3. Run with pytest tests/test_login.py -v. Three lines of green confirm all tests passed.

  4. 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).

  5. 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 printed browser_name per browser per test.

  6. Stretch: drop --headed from pytest.ini and re-run. The whole suite executes headlessly in seconds — the same shape you'd run in CI. Add --workers 4 (after installing pytest-xdist later, 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.

// tip to track lessons you complete and pick up where you left off across devices.