Skip to content

Blog / Playwright Python and pytest

Playwright Python and pytest

Page Objects in Playwright Python: Classes, Fixtures and pytest

A Python take on page objects for Playwright, wired in with pytest fixtures instead of inheritance.

October 23, 2026 · Blendra Team · 12 min read

A Playwright Python page object model needs less machinery than you might expect. You need plain classes that hold locators and actions, a conftest.py whose fixtures build those classes, and tests that ask for them by name. You don't need a BasePage hierarchy or a driver singleton. This guide builds that setup for a small demo shop. It covers shared widgets as components, type hints that let mypy catch typos before a browser starts, and a folder layout with room to grow. It also shows the errors you hit when the wiring is wrong.

Versions used, as of October 2026: Python 3.12, Playwright for Python 1.64.0, pytest 9.1.1, pytest-playwright 0.10.0 and mypy 2.4.0. If pytest-playwright itself is new to you, start with Playwright with pytest and come back.

TL;DR

  • A page object is a class whose __init__ takes a Page and defines locators. Methods perform actions. Assertions stay in the tests.
  • Fixtures in conftest.py create page objects (def login_page(page: Page) -> LoginPage). Tests request them as arguments, so no test calls a constructor.
  • Keep page object fixtures function-scoped. A session-scoped one fails with ScopeMismatch, because pytest-playwright's page is per test.
  • Widgets that repeat, like a header on every screen or a card in a list, become component classes that pages hold as attributes. Use composition, not inheritance.
  • Annotate everything and run mypy. A misspelled method fails in seconds, with a "maybe" hint, before any browser starts.
  • Layout: pages/, components/, data/, tests/ and a root conftest.py, with pythonpath = ["."] in pyproject.toml.

Layers of the suite: tests, conftest.py fixtures, pages, components and the page fixture

A minimal page object class

The demo shop has a sign-in page, a product list and a cart. Here is the page object for sign-in, saved as pages/login_page.py. It sits next to an empty pages/__init__.py.

from playwright.sync_api import Page

from pages.products_page import ProductsPage


class LoginPage:
    def __init__(self, page: Page) -> None:
        self.page = page
        self.heading = page.get_by_role("heading", level=1)
        self.email = page.get_by_label("Email")
        self.password = page.get_by_label("Password")
        self.submit_button = page.get_by_role("button", name="Sign in")
        self.error = page.get_by_role("alert")

    def goto(self) -> None:
        self.page.goto("/login/")

    def submit(self, email: str, password: str) -> None:
        self.email.fill(email)
        self.password.fill(password)
        self.submit_button.click()

    def sign_in(self, email: str, password: str) -> ProductsPage:
        self.submit(email, password)
        self.page.wait_for_url("**/products/")
        return ProductsPage(self.page)

This is the same shape as the official Playwright Python POM page: store the page, define locators in __init__, add methods. Four decisions make it hold up in a real suite.

Locators are attributes, created once. A Locator is lazy. Creating one doesn't touch the browser. It finds the element each time an action or expect uses it, so defining all of them in __init__ costs nothing. Don't store values the same way. self.title = page.title() reads the title once, when the object is built, and goes stale after the first click.

Two methods for two intents. submit() fills and clicks, then returns. Use it when you expect to stay on the page, for example to see an error. sign_in() also waits for the products URL and returns the next page object. If sign_in() were the only method, a wrong-password test would time out waiting for a URL that never comes. Its own error would never show.

No assertions inside. The page object exposes self.error, and the test decides what it should say. When a check fails, the traceback points at the test line that states the expectation, not at a helper three files away. The TypeScript page object guide covers the case for and against assertions in page objects in more depth. The short version: keep them out unless the check is part of what the action means.

Roles and labels first. get_by_role and get_by_label survive markup changes that break CSS selectors. The ranking is in Playwright locators.

Create page objects with pytest fixtures

You could write login_page = LoginPage(page) at the top of every test. That works, but every test repeats it, and setup such as signing in gets copied as well. pytest fixtures remove both. Here is the project's conftest.py:

import os

import pytest
from playwright.sync_api import Page

from data.users import User
from pages.cart_page import CartPage
from pages.login_page import LoginPage
from pages.products_page import ProductsPage


@pytest.fixture(scope="session")
def user() -> User:
    email = os.environ.get("E2E_EMAIL")
    password = os.environ.get("E2E_PASSWORD")
    if not email or not password:
        pytest.fail("Set E2E_EMAIL and E2E_PASSWORD to run these tests.", pytrace=False)
    return User(email, password)


@pytest.fixture
def login_page(page: Page) -> LoginPage:
    return LoginPage(page)


@pytest.fixture
def products_page(login_page: LoginPage, user: User) -> ProductsPage:
    # Setup lives here, not in the test: every test that asks for
    # products_page starts signed in, on the products page.
    login_page.goto()
    return login_page.sign_in(user.email, user.password)


@pytest.fixture
def cart_page(page: Page) -> CartPage:
    return CartPage(page)

User is a two-field frozen dataclass in data/users.py. It lives in a module, not in conftest.py, so tests can import it for type hints. And the tests:

from playwright.sync_api import expect

from pages.login_page import LoginPage
from pages.products_page import ProductsPage


def test_wrong_password_shows_an_error(login_page: LoginPage) -> None:
    login_page.goto()
    login_page.submit("ada@example.com", "not-the-password")
    expect(login_page.error).to_have_text("Wrong email or password")


def test_sign_in_opens_the_products_page(products_page: ProductsPage) -> None:
    expect(products_page.heading).to_have_text("Products")
    expect(products_page.header.cart_count).to_have_text("0")


def test_sign_out_returns_to_sign_in(products_page: ProductsPage) -> None:
    login_page = products_page.header.sign_out()
    expect(login_page.heading).to_have_text("Sign in")
E2E_EMAIL=ada@example.com E2E_PASSWORD=correct-horse pytest -v
tests/test_cart.py::test_adding_items_updates_the_badge[chromium] PASSED
tests/test_cart.py::test_removing_an_item_updates_the_total[chromium] PASSED
tests/test_login.py::test_wrong_password_shows_an_error[chromium] PASSED
tests/test_login.py::test_sign_in_opens_the_products_page[chromium] PASSED
tests/test_login.py::test_sign_out_returns_to_sign_in[chromium] PASSED
tests/test_products.py::test_product_card_shows_its_price[chromium-mug] PASSED
tests/test_products.py::test_product_card_shows_its_price[chromium-notebook] PASSED

============================== 7 passed in 5.01s ===============================

Why fixtures beat a base class here:

  • Fixtures can request fixtures. products_page asks for login_page and user, signs in, and hands back a ready page. A test that needs a signed-in user asks for products_page and gets one. See fixtures can request other fixtures in the pytest docs.
  • All page objects in a test share one Page. pytest caches a fixture's value for the length of a test. When a test asks for both products_page and cart_page, both wrap the same page, so one can navigate and the other can read the result. The pytest docs describe this caching.
  • Setup and teardown sit in one place. A fixture can yield its object and clean up afterwards, for example to delete an order the test created. A base class would need setup_method hooks, and those don't mix with fixture arguments.

Keep page object fixtures function-scoped

It's tempting to build page objects once per session "for speed". pytest refuses, because pytest-playwright's page fixture is function-scoped (Playwright docs). This fragment (imports as in conftest.py) shows the problem:

@pytest.fixture(scope="session")
def shared_login_page(page: Page) -> LoginPage:
    return LoginPage(page)
_______________ ERROR at setup of test_sign_in_heading[chromium] _______________
ScopeMismatch: You tried to access the function scoped fixture page with a session scoped request object.

Building a page object costs microseconds, so there's nothing to save. The slow part is signing in. For that, keep the fixtures function-scoped and start each test's context from saved storage state, as in Playwright authentication. The user fixture can be session-scoped because it holds only data.

Components and shared widgets

The header with the cart badge and the sign-out button appears on every signed-in screen. Model it once, as a component in components/site_header.py:

from __future__ import annotations

from typing import TYPE_CHECKING

from playwright.sync_api import Page

if TYPE_CHECKING:
    # Imported for type hints only: pages.login_page imports this module (through
    # pages.products_page), so a runtime import here would be circular.
    from pages.login_page import LoginPage


class SiteHeader:
    def __init__(self, page: Page) -> None:
        self.page = page
        self.root = page.get_by_role("navigation", name="Main")
        self.cart_link = self.root.get_by_role("link", name="Cart")
        self.cart_count = self.root.get_by_test_id("cart-count")
        self.sign_out_button = self.root.get_by_role("button", name="Sign out")

    def open_cart(self) -> None:
        self.cart_link.click()
        self.page.wait_for_url("**/cart/")

    def sign_out(self) -> LoginPage:
        from pages.login_page import LoginPage

        self.sign_out_button.click()
        self.page.wait_for_url("**/login/")
        return LoginPage(self.page)

Every locator starts from self.root, so a "Cart" link in the page footer can never match by accident. Pages hold the component as an attribute (self.header = SiteHeader(page)), and tests reach it as products_page.header.cart_count. That is composition. The alternative, a BasePage that defines the header for everyone, breaks down when one screen has no header or a different one. With composition, each page holds exactly the widgets it shows.

Breaking the circular import

sign_out() returns a LoginPage, and that creates a loop. login_page imports products_page, which imports site_header, which would import login_page. With a plain import at the top of site_header.py, pytest can't even load conftest.py:

pages/products_page.py:4: in <module>
    from components.site_header import SiteHeader
E   ImportError: cannot import name 'SiteHeader' from partially initialized module 'components.site_header' (most likely due to a circular import)

The fix above has three parts. from __future__ import annotations stops Python from evaluating type hints at runtime. The if TYPE_CHECKING: import is seen by mypy and your IDE only. The import inside sign_out() runs when the method is called, after every module has loaded. If a page object needs that trick more than once, return None from the method instead and let the test ask for the next page through a fixture.

Components scoped to one item

A product card on the products page repeats once per product. The component takes a Locator for one card's root instead of the whole Page. Save it as components/product_card.py:

from playwright.sync_api import Locator


class ProductCard:
    """One product tile. It only knows its own root, so it works in any list."""

    def __init__(self, root: Locator) -> None:
        self.root = root
        self.name = root.get_by_role("heading", level=2)
        self.price = root.locator("p")
        self.add_button = root.get_by_role("button", name="Add to cart")

    def add_to_cart(self) -> None:
        self.add_button.click()

The page object hands out cards by name. This is pages/products_page.py:

from playwright.sync_api import Page

from components.product_card import ProductCard
from components.site_header import SiteHeader


class ProductsPage:
    def __init__(self, page: Page) -> None:
        self.page = page
        self.header = SiteHeader(page)
        self.heading = page.get_by_role("heading", level=1)

    def goto(self) -> None:
        self.page.goto("/products/")

    def product(self, name: str) -> ProductCard:
        return ProductCard(self.page.get_by_role("article", name=name))

    def add_to_cart(self, *names: str) -> None:
        for name in names:
            self.product(name).add_to_cart()

It pairs well with parametrize:

import pytest
from playwright.sync_api import expect

from pages.products_page import ProductsPage


@pytest.mark.parametrize(
    ("name", "price"),
    [
        pytest.param("Trail mug", "$12", id="mug"),
        pytest.param("Field notebook", "$9", id="notebook"),
    ],
)
def test_product_card_shows_its_price(
    products_page: ProductsPage, name: str, price: str
) -> None:
    card = products_page.product(name)
    expect(card.name).to_have_text(name)
    expect(card.price).to_have_text(price)

Actions return before the app is done

Here is one failure we hit while writing this post. In the demo, "Add to cart" saves the item after a 250 ms round trip. add_to_cart() returns as soon as the click lands. A test that clicked and then went straight to the cart found it empty, in three runs out of three:

E       AssertionError: Locator expected to have text '$9'
E       Actual value: $0

The fix belongs in the test, not in a sleep. Wait for the proof the app gives you before you move on. Here that proof is the badge. This is tests/test_cart.py:

from playwright.sync_api import expect

from pages.cart_page import CartPage
from pages.products_page import ProductsPage


def test_adding_items_updates_the_badge(products_page: ProductsPage) -> None:
    products_page.add_to_cart("Trail mug", "Canvas tote")
    expect(products_page.header.cart_count).to_have_text("2")


def test_removing_an_item_updates_the_total(
    products_page: ProductsPage, cart_page: CartPage
) -> None:
    products_page.add_to_cart("Trail mug", "Canvas tote")
    expect(products_page.header.cart_count).to_have_text("2")
    products_page.header.open_cart()

    expect(cart_page.items).to_have_count(2)
    expect(cart_page.total).to_have_text("$30")

    cart_page.remove("Trail mug")
    expect(cart_page.items).to_have_count(1)
    expect(cart_page.item("Canvas tote")).to_be_visible()
    expect(cart_page.total).to_have_text("$18")
    expect(cart_page.header.cart_count).to_have_text("1")

CartPage follows the same pattern. items is page.get_by_role("row") filtered to rows that contain a "Remove" button, so the header row never counts. item(name) filters those rows by text, and remove(name) clicks the button inside that row. If every caller needs the same wait after an action, putting it in the method is fine. That's why sign_in() waits for the URL. Just keep it a wait for a real signal, never a fixed delay.

Typing and IDE help

Every attribute above is a Locator, and every method and fixture has a return type. That pays off twice. Your editor completes products_page.header. with the header's locators. And a type checker reads the whole suite in a couple of seconds (1.8 s for this project, from a cold cache), without opening a browser. Turn on strict mode in pyproject.toml:

[tool.mypy]
strict = true
mypy conftest.py data pages components tests
Success: no issues found in 13 source files

Now misspell a method and pass an int where Playwright expects text. This is a fragment; the imports match tests/test_cart.py:

def test_typo(products_page: ProductsPage) -> None:
    products_page.add_to_card("Trail mug")
    expect(products_page.header.cart_count).to_have_text(1)
pitfalls/test_typo.py:12: error: "ProductsPage" has no attribute "add_to_card"; maybe "add_to_cart"?  [attr-defined]
pitfalls/test_typo.py:13: error: Argument 1 to "to_have_text" of "LocatorAssertions" has incompatible type "int"; expected "str | Pattern[str] | Sequence[str] | Sequence[Pattern[str]] | Sequence[str | Pattern[str]]"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Without the check, pytest would find the first bug only after it launched Chromium and signed in. Playwright for Python ships its own type hints, so you install nothing extra. Run the same command as a CI step before the browser job.

Two typing habits help:

  • Annotate fixture arguments in tests (products_page: ProductsPage). pytest ignores the annotation, but mypy and your editor use it. Without it, the argument is untyped and nothing inside the test is checked.
  • Annotate the fixture's return type (-> LoginPage). For a yield fixture, use Iterator[LoginPage] from collections.abc.

Don't start a page object's name with "Test"

pytest collects every class whose name starts with Test from a test module's namespace, including classes you imported. An app with a "Test runs" screen gets a TestRunsPage, and pytest tries to collect it:

PytestCollectionWarning: cannot collect test class 'TestRunsPage' because it has a __init__ constructor (from: pitfalls/test_runs_pages.py)

It's only a warning, but it is noise in every run. With -W error, it becomes a collection error and the run stops. Add __test__ = False to the class, as the pytest docs describe, or pick a name like RunsPage.

Folder layout for a Playwright Python page object model

The full project behind this post looks like this:

shop-e2e/
├── pyproject.toml          # pytest + mypy settings
├── requirements.txt        # pinned versions
├── conftest.py             # fixtures: user, login_page, products_page, cart_page
├── data/
│   ├── __init__.py
│   └── users.py            # User dataclass
├── components/
│   ├── __init__.py
│   ├── product_card.py     # class ProductCard
│   └── site_header.py      # class SiteHeader
├── pages/
│   ├── __init__.py
│   ├── cart_page.py        # class CartPage
│   ├── login_page.py       # class LoginPage
│   └── products_page.py    # class ProductsPage
└── tests/
    ├── test_cart.py
    ├── test_login.py
    └── test_products.py

The naming follows Python conventions. Modules are snake_case and named after the class (login_page.py holds LoginPage). Packages are plural nouns. Only test files start with test_. And pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "--strict-markers --import-mode=importlib"
base_url = "http://localhost:8000"

[tool.mypy]
strict = true

Don't drop pythonpath = ["."]. Without it, --import-mode=importlib leaves sys.path alone, and the root conftest.py can't import your packages:

ImportError while loading conftest '.../conftest.py'.
conftest.py:7: in <module>
    from pages.cart_page import CartPage
E   ModuleNotFoundError: No module named 'pages'

The confusing part is that python -m pytest still passes, because python -m puts the current directory on sys.path. So the suite works on one laptop and fails in CI, which runs a bare pytest. With the setting in place, both commands behave the same.

When the suite grows

  • Group tests by feature: tests/checkout/, tests/account/. A conftest.py inside a feature folder holds fixtures only that feature uses. pytest applies it to that folder and below.
  • Move fixtures out of a long root conftest.py. Put them in a module such as fixtures/page_fixtures.py and load it from the root conftest.py with pytest_plugins = ["fixtures.page_fixtures"]. Declare it in the root conftest.py only, because pytest deprecates pytest_plugins in non-root conftest files.
  • Split pages by feature once pages/ passes 20 or 30 modules: pages/checkout/payment_page.py. Keep components shared.
  • Keep test data out of page objects. Values go in data/ (dataclasses, JSON files, factories) or in parametrize. A page object that hard-codes "ada@example.com" works for exactly one test. Playwright test data covers seeding and cleanup.

One fixture per page, or one object for the app?

Some teams prefer a single fixture that holds every page. This fragment needs the imports from conftest.py plus expect:

class Shop:
    def __init__(self, page: Page) -> None:
        self.login = LoginPage(page)
        self.products = ProductsPage(page)
        self.cart = CartPage(page)


@pytest.fixture
def shop(page: Page) -> Shop:
    return Shop(page)


def test_cart_total(shop: Shop, user: User) -> None:
    shop.login.goto()
    shop.login.sign_in(user.email, user.password)
    shop.products.add_to_cart("Field notebook")
    expect(shop.products.header.cart_count).to_have_text("1")
    shop.products.header.open_cart()
    expect(shop.cart.total).to_have_text("$9")

It saves fixture boilerplate and makes discovery easy: type shop. and the editor lists every screen. The cost is that a test's signature no longer says which screens it touches, and setup like signing in moves back into the test body. One fixture per page, with setup fixtures such as products_page, scales better once several people write tests. Pick one style per suite.

How Blendra's Python export structures pages

Blendra records flows in your app and exports them as a test suite you own. The Playwright Python export is Beta and available on Pro+ and Team. It uses the conventions above, with a few choices of its own:

  • Page objects are classes in snake_case modules under pages/: class LoginPage lives in pages/login_page.py.
  • Selectors live in locators/, one dictionary per page (LOGIN_LOCATORS in locators/login_locators.py). Each selector there must match exactly one element, and they follow this order of preference: test id, role with an accessible name, label, placeholder, then stable attributes and text.
  • Every generated page class sets __test__ = False, so a page named TestCheckout never trips pytest's collector.
  • Methods are one per recorded action, such as fill_email(self, value: str) -> None. They express intent only, because checks and test data live in the tests.
  • Tests create the page objects they use in the test body, before the first step (login_page = LoginPage(page)). They don't receive them as fixtures. The generated conftest.py holds the suite's own fixtures and registers its results reporter.

If you add hand-written tests to an exported suite, you can wrap those classes in fixtures exactly as this post does. Plans and limits are on the pricing page.

FAQ

Should my page objects inherit from a BasePage?

Usually not. A base class tends to collect unrelated helpers (wait_for_spinner, login, screenshot) until every page drags in all of them. Shared widgets work better as components that a page holds, and shared setup works better as fixtures. A tiny base class is fine if it only stores self.page and a goto() that every page implements. Stop there.

Can a page object method return the next page object?

Yes, when the transition always happens, as with sign_in() returning ProductsPage. It reads well and gives the type checker the next page's attributes. Avoid it when the destination depends on data, for example a form that sometimes shows an error. And if two pages return each other, use the TYPE_CHECKING import shown above.

Do page objects work with the async API?

The structure is the same: methods become async def, and each Playwright call gets an await. The Playwright docs show both versions side by side. With pytest you then need the async plugin, pytest-playwright-asyncio, instead of pytest-playwright. Playwright Python: sync or async? covers when that is worth it.

Where do data-testid values go?

Inside the page or component, as locators: self.cart_count = self.root.get_by_test_id("cart-count"). Tests never mention them. Reach for a test id when an element has no stable role or accessible name, like the bare number in the cart badge. data-testid best practices covers naming and ownership.

How do I make the signed-in fixture fast?

Sign in once per session, save the browser's storage state, and have the per-test fixture open its context from that file. Playwright with pytest has a complete logged_in_page fixture for this. The page object fixtures don't change. They wrap whichever page the auth fixture gives them.

Page objectspytestPython

Start with the flow you test by hand every release.

Start free →